Initial crossword addition and arcade redesign
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s

This commit is contained in:
Elijah 2026-07-14 19:32:41 -07:00
parent 611a585757
commit 5bec95fb30
32 changed files with 1635 additions and 56 deletions

View file

@ -8,11 +8,12 @@ export default async function ConnectionsPlayPage(
) {
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
const pack = await getArcadePack(packId);
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections") notFound();
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound();
const normalized = pack.normalized;
const mistakesValue = Number(query.mistakes);
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
? mistakesValue
: pack.normalized.settings.allowedMistakes;
: normalized.settings.allowedMistakes;
const Renderer = ARCADE_RENDERERS.connections;
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={pack.normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
}

View file

@ -0,0 +1,21 @@
import { randomUUID } from "node:crypto";
import { notFound } from "next/navigation";
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade";
import { getArcadePack } from "@/services/arcadeService";
const SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
const pack = await getArcadePack(packId);
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound();
const requestedSize = typeof query.size === "string" ? query.size : "standard";
const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard";
const seed = randomUUID();
const normalized = pack.normalized as NormalizedCrosswordPack;
const layout = generateCrosswordLayout(normalized, size, seed);
const Renderer = ARCADE_RENDERERS.crossword;
return <Renderer seed={seed} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} layout={layout} settings={{ size, instantCheck: query.instant === "1", allowHints: query.hints !== "0" }} />;
}

View file

@ -0,0 +1,12 @@
import { notFound } from "next/navigation";
import { CrosswordHub } from "@/components/arcade/CrosswordHub";
import { getClassBySlug } from "@/services/classService";
import { listArcadePacks } from "@/services/arcadeService";
export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) {
const { classSlug } = await props.params;
const classItem = await getClassBySlug(classSlug);
if (!classItem) notFound();
const packs = await listArcadePacks(classItem.id, "crossword");
return <CrosswordHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
}

View file

@ -37,7 +37,7 @@ export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">
<div className="arcade-cabinet-grid grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{ARCADE_GAMES.map((game) => {
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? "Play Connections →" : "Coming soon"}</span></div></article>;
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? `Play ${game.name}` : "Coming soon"}</span></div></article>;
return game.available ? <Link key={game.key} href={`/${classSlug}/arcade/${game.path}`} className="rounded-[1.75rem] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">{card}</Link> : <div key={game.key}>{card}</div>;
})}
</div>

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
@ -9,7 +9,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
}
try {
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.rawJson));
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson));
} catch (error) {
if (error instanceof ArcadeImportError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadeAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
@ -17,12 +17,19 @@ export async function POST(
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
const parsed = arcadeAttemptCreateSchema.safeParse(await request.json().catch(() => null));
const pack = await arcadeService.getArcadePack(id);
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
const body = await request.json().catch(() => null);
const parsed = pack.gameType === "crossword"
? crosswordAttemptCreateSchema.safeParse(body)
: arcadeAttemptCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
}
try {
const attempt = await arcadeService.createArcadeAttempt(id, parsed.data);
const attempt = pack.gameType === "crossword"
? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters<typeof arcadeService.createCrosswordAttempt>[1])
: await arcadeService.createArcadeAttempt(id, parsed.data as Parameters<typeof arcadeService.createArcadeAttempt>[1]);
return attempt
? NextResponse.json(attempt, { status: 201 })
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });

View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import type { CrosswordSize } from "@/types/arcade";
import { getArcadePack } from "@/services/arcadeService";
const CROSSWORD_SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params;
const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard";
if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) {
return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 });
}
const pack = await getArcadePack(id);
if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") {
return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 });
}
const size = sizeValue as CrosswordSize;
return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`));
}

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
@ -7,7 +7,7 @@ export async function GET(request: NextRequest) {
const params = new URL(request.url).searchParams;
const classId = params.get("classId");
const gameType = params.get("gameType");
if (!classId || gameType !== "connections") {
if (!classId || (gameType !== "connections" && gameType !== "crossword")) {
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
}
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));

View file

@ -3,7 +3,7 @@ import * as settingsService from "@/services/settingsService";
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
return type === "flashcards" || type === "quizzes" || type === "connections" ? type : null;
return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null;
}
export async function GET(request: NextRequest) {

View file

@ -498,6 +498,206 @@ body:has(.connections-world) {
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
body:has(.crossword-world) {
--theme-bg-base: #20170f;
--theme-bg-surface: #f6ecd2;
--theme-bg-surface-alt: #ead9b5;
--theme-bg-callout: #dfc795;
--theme-primary: #9a5a24;
--theme-primary-hover: #7b431b;
--theme-text-heading: #2d2117;
--theme-text-body: #493725;
--theme-text-secondary: #68523a;
--theme-text-muted: #8a7052;
--theme-border: #a9865d;
--theme-border-light: #cfb88e;
background:
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
}
body:has(.crossword-world)::before {
opacity: .16;
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
}
body:has(.crossword-world) .app-sidebar,
body:has(.crossword-world) .app-mobile-header {
--theme-bg-surface-alt: #3a2819;
--theme-bg-callout: #e4cc98;
--theme-primary: #8a4f20;
--theme-text-heading: #fff3d8;
--theme-text-secondary: #d7c3a2;
--theme-text-muted: #aa9170;
--theme-border-light: #5b4028;
color: #f8ead0;
border-color: rgba(224, 190, 126, .22);
background: rgba(35, 23, 14, .94);
}
.crossword-world {
position: relative;
color: var(--theme-text-body);
}
.crossword-hub.crossword-world,
.crossword-stage.crossword-world { overflow: visible; }
.crossword-hero,
.crossword-setup,
.crossword-active-clue,
.crossword-clues,
.crossword-import,
.crossword-paper {
border: 1px solid rgba(100, 68, 35, .24);
background:
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
#f4e7c9;
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
}
.crossword-hero { position: relative; overflow: hidden; }
.crossword-hero > * { position: relative; z-index: 1; }
.crossword-hero::after {
content: "DAILY STUDY";
position: absolute;
right: 1rem;
top: .35rem;
color: rgba(77,51,28,.07);
font-family: Georgia, serif;
font-size: clamp(2.75rem, 5vw, 5rem);
font-weight: 900;
letter-spacing: -.06em;
line-height: 1;
pointer-events: none;
}
.crossword-kicker,
.crossword-section-label {
color: #925623;
font-size: .68rem;
font-weight: 900;
letter-spacing: .18em;
text-transform: uppercase;
}
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
.crossword-primary {
color: #fff8e7;
background: linear-gradient(135deg, #a86429, #744018);
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
transition: transform .16s var(--ease-spring), filter .16s ease;
}
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
.crossword-pack {
border-color: rgba(111,76,43,.25);
background: rgba(244,231,201,.92);
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
transition: transform .18s ease, border-color .18s ease;
}
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
.crossword-size {
color: var(--theme-text-secondary);
border: 1px solid var(--theme-border-light);
background: rgba(255,250,235,.48);
}
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
.crossword-option strong,.crossword-option small { display: block; }
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
.crossword-active-clue { display: grid; gap: .2rem; }
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
.crossword-active-clue small { color: var(--theme-text-muted); }
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
.crossword-board-viewport {
max-height: min(68vh, 46rem);
overflow: auto;
padding: 1rem;
overscroll-behavior: contain;
background: #2a1d13;
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
}
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
.crossword-cell {
position: relative;
display: grid;
width: var(--crossword-cell);
height: var(--crossword-cell);
place-items: center;
color: #25190f;
border: 1px solid #6f5335;
background: #fff8e6;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: background .12s ease, box-shadow .12s ease;
}
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
.crossword-cell.is-word { background: #f2d99f; }
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
.crossword-secondary {
min-height: 2.75rem;
padding: .6rem .9rem;
border: 1px solid #9f7a50;
border-radius: .7rem;
color: #392718;
background: #ead6ae;
font-size: .78rem;
font-weight: 850;
box-shadow: 0 3px 0 #aa895f;
}
.crossword-secondary:hover { background: #f5e5c4; }
.crossword-clues { max-height: 72vh; overflow: hidden; }
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
.crossword-clues li b { color: #8a4f20; }
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
@media (max-width: 639px) {
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
.crossword-board { place-content: start; min-height: 24rem; }
.crossword-clues { max-height: 28rem; }
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
}
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }

View file

@ -8,12 +8,14 @@ export function ArcadeGameShell({
exitHref,
elapsedSeconds,
complete,
worldClassName = "connections-world connections-stage",
children,
}: {
title: string;
exitHref: string;
elapsedSeconds: number;
complete: boolean;
worldClassName?: string;
children: ReactNode;
}) {
const router = useRouter();
@ -24,8 +26,8 @@ export function ArcadeGameShell({
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
return (
<div className="connections-world connections-stage mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3">
<header className="connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
<div className={`${worldClassName} mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3`}>
<header className="arcade-game-topbar connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
<button onClick={exitGame} className="min-h-10 rounded-xl px-3 text-sm font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"> Exit</button>
<h1 className="truncate text-center text-lg font-extrabold text-text-heading">{title}</h1>
<div className="min-w-16 text-right font-mono text-sm font-bold text-text-muted" aria-label={`${elapsedSeconds} seconds elapsed`}>{minutes}:{seconds}</div>

View file

@ -2,14 +2,16 @@
import { useState } from "react";
import { GenerateTab } from "@/components/import/GenerateTab";
import type { ArcadeImportBatchPreview } from "@/types/arcade";
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
export function ArcadeImportModal({
classId,
gameType,
onClose,
onImported,
}: {
classId: string;
gameType: ArcadeGameKey;
onClose: () => void;
onImported: () => void;
}) {
@ -31,7 +33,7 @@ export function ArcadeImportModal({
const response = await fetch("/api/arcade/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameType: "connections", rawJson }),
body: JSON.stringify({ gameType, rawJson }),
});
const data = await response.json();
if (!response.ok) {
@ -56,9 +58,9 @@ export function ArcadeImportModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
gameType: "connections",
gameType,
rawJson,
names: names.map((name) => name.trim()),
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
warningsAcknowledged: acknowledged,
}),
});
@ -77,9 +79,9 @@ export function ArcadeImportModal({
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className="connections-world connections-import relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl">
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className={`${gameType === "connections" ? "connections-world connections-import" : "crossword-world crossword-import"} relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl`}>
<div className="flex items-center justify-between px-6 pt-5">
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import Connections pack</h2>
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import {gameType === "connections" ? "Connections" : "Crossword"} pack</h2>
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
</div>
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
@ -88,10 +90,10 @@ export function ArcadeImportModal({
))}
</div>
<div className="overflow-y-auto p-6">
{tab === "generate" ? <GenerateTab importType="connections" /> : (
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
<div className="space-y-4">
<p className="text-sm text-text-secondary">Paste one pack object or an array of up to ten packs. Every board is validated before anything is saved.</p>
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder="Paste Connections JSON object or array here…" className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
<p className="text-sm text-text-secondary">{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.</p>
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder={`Paste ${gameType === "connections" ? "Connections" : "Crossword"} JSON here…`} className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
{preview && (
@ -102,8 +104,13 @@ export function ArcadeImportModal({
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
{gameType === "connections" ? <>
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
</> : <>
<p className="mt-2 text-xs text-text-muted">80 entries · four board sizes</p>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-text-secondary">{pack.layoutPreviews?.map((layout) => <span key={layout.size} className="rounded-lg bg-bg-surface-alt px-2 py-1.5 capitalize">{layout.size}: {layout.placedCount}/{layout.targetCount}</span>)}</div>
</>}
</article>
))}
</div>

View file

@ -117,7 +117,7 @@ export function ConnectionsHub({
</aside>
</div>
)}
{showImport && <ArcadeImportModal classId={classId} onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
{showImport && <ArcadeImportModal classId={classId} gameType="connections" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
</div>
);
}

View file

@ -0,0 +1,264 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
import type {
CrosswordAction,
CrosswordLayout,
CrosswordPlacedEntry,
CrosswordRoundResult,
CrosswordSessionSettings,
NormalizedCrosswordPack,
} from "@/types/arcade";
function entryValue(entry: CrosswordPlacedEntry, answers: Record<string, string>) {
return entry.cellKeys.map((cellKey) => answers[cellKey] ?? "").join("");
}
export function CrosswordGame({ seed, packId, packName, classSlug, layout, settings }: {
seed: string;
packId: string;
packName: string;
classSlug: string;
pack: NormalizedCrosswordPack;
layout: CrosswordLayout;
settings: CrosswordSessionSettings;
}) {
const orderedEntries = useMemo(() => [...layout.entries].sort((a, b) => a.number - b.number || a.direction.localeCompare(b.direction)), [layout.entries]);
const entryById = useMemo(() => new Map(orderedEntries.map((entry) => [entry.id, entry])), [orderedEntries]);
const cellByKey = useMemo(() => new Map(layout.cells.map((cell) => [cell.key, cell])), [layout.cells]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [activeEntryId, setActiveEntryId] = useState(orderedEntries[0]?.id ?? "");
const [activeCellKey, setActiveCellKey] = useState(orderedEntries[0]?.cellKeys[0] ?? "");
const [clueTab, setClueTab] = useState<"across" | "down">(orderedEntries[0]?.direction ?? "across");
const [actions, setActions] = useState<CrosswordAction[]>([]);
const [checkedWrong, setCheckedWrong] = useState<Set<string>>(new Set());
const [revealedCells, setRevealedCells] = useState<Set<string>>(new Set());
const [alternateClues, setAlternateClues] = useState<Set<string>>(new Set());
const [feedback, setFeedback] = useState(`${layout.entries.length} of ${layout.targetCount} target words placed.`);
const [zoom, setZoom] = useState(1);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [result, setResult] = useState<CrosswordRoundResult | null>(null);
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const startedAt = useRef(0);
const mobileInput = useRef<HTMLInputElement>(null);
const activeEntry = entryById.get(activeEntryId) ?? orderedEntries[0];
useEffect(() => {
if (startedAt.current === 0) startedAt.current = Date.now();
if (result) return;
const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
return () => window.clearInterval(timer);
}, [result]);
function selectEntry(entry: CrosswordPlacedEntry, cellKey = entry.cellKeys[0]) {
setActiveEntryId(entry.id);
setActiveCellKey(cellKey);
setClueTab(entry.direction);
}
function selectCell(cellKey: string) {
const cell = cellByKey.get(cellKey);
if (!cell) return;
let nextId = cell.entryIds[0];
if (cell.entryIds.includes(activeEntryId) && cell.entryIds.length > 1 && activeCellKey === cellKey) {
nextId = cell.entryIds.find((id) => id !== activeEntryId) ?? nextId;
} else if (!cell.entryIds.includes(activeEntryId)) {
nextId = cell.entryIds.find((id) => entryById.get(id)?.direction === clueTab) ?? nextId;
} else {
nextId = activeEntryId;
}
const entry = entryById.get(nextId);
if (entry) selectEntry(entry, cellKey);
mobileInput.current?.focus({ preventScroll: true });
}
function moveWithinEntry(offset: number) {
if (!activeEntry) return;
const index = Math.max(0, activeEntry.cellKeys.indexOf(activeCellKey));
setActiveCellKey(activeEntry.cellKeys[Math.max(0, Math.min(activeEntry.cellKeys.length - 1, index + offset))]);
}
function writeLetter(letter: string) {
if (!activeEntry || !activeCellKey || result || revealedCells.has(activeCellKey)) return;
const upper = letter.replace(/[^A-Za-z]/g, "").slice(-1).toUpperCase();
if (!upper) return;
const nextAnswers = { ...answers, [activeCellKey]: upper };
setAnswers(nextAnswers);
if (settings.instantCheck) {
const value = activeEntry.cellKeys.map((cellKey) => nextAnswers[cellKey] ?? "").join("");
if (value.length === activeEntry.answer.length) {
setCheckedWrong((checked) => {
const updated = new Set(checked);
activeEntry.cellKeys.forEach((cellKey, index) => {
if ((nextAnswers[cellKey] ?? "") === activeEntry.answer[index]) updated.delete(cellKey);
else updated.add(cellKey);
});
return updated;
});
}
}
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
moveWithinEntry(1);
}
function clearLetter() {
if (!activeCellKey || revealedCells.has(activeCellKey)) return;
if (answers[activeCellKey]) {
setAnswers((current) => { const next = { ...current }; delete next[activeCellKey]; return next; });
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
} else {
moveWithinEntry(-1);
}
}
function cycleEntry(offset: number) {
const index = Math.max(0, orderedEntries.findIndex((entry) => entry.id === activeEntryId));
const entry = orderedEntries[(index + offset + orderedEntries.length) % orderedEntries.length];
selectEntry(entry);
}
function handleKeyDown(event: React.KeyboardEvent) {
if (/^[a-zA-Z]$/.test(event.key)) { event.preventDefault(); writeLetter(event.key); return; }
if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); clearLetter(); return; }
if (event.key === "Tab") { event.preventDefault(); cycleEntry(event.shiftKey ? -1 : 1); return; }
if (event.key === "Enter") { event.preventDefault(); checkWord(); return; }
const cell = cellByKey.get(activeCellKey);
if (!cell || !event.key.startsWith("Arrow")) return;
event.preventDefault();
const delta = event.key === "ArrowLeft" ? [0, -1] : event.key === "ArrowRight" ? [0, 1] : event.key === "ArrowUp" ? [-1, 0] : [1, 0];
const next = cellByKey.get(`${cell.row + delta[0]}:${cell.column + delta[1]}`);
if (next) selectCell(next.key);
}
function incorrectCells(entry: CrosswordPlacedEntry) {
return entry.cellKeys.filter((cellKey, index) => (answers[cellKey] ?? "") !== entry.answer[index]);
}
function checkWord() {
if (!activeEntry) return;
const wrong = incorrectCells(activeEntry);
setCheckedWrong((current) => new Set([...current, ...wrong]));
setActions((current) => [...current, { type: "CHECK_WORD", entryId: activeEntry.id, value: entryValue(activeEntry, answers), elapsedMs: Date.now() - startedAt.current }]);
setFeedback(wrong.length === 0 ? `${activeEntry.number} ${activeEntry.direction} is correct.` : `${wrong.length} cell${wrong.length === 1 ? " is" : "s are"} incorrect in this word.`);
}
function checkPuzzle() {
const wrong = orderedEntries.flatMap(incorrectCells);
setCheckedWrong(new Set(wrong));
setActions((current) => [...current, { type: "CHECK_PUZZLE", answers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])), elapsedMs: Date.now() - startedAt.current }]);
setFeedback(wrong.length === 0 ? "Every filled answer is correct." : `${wrong.length} cells need another look.`);
}
function showAlternateClue() {
if (!activeEntry || !settings.allowHints) return;
setAlternateClues((current) => new Set(current).add(activeEntry.id));
setActions((current) => [...current, { type: "ALTERNATE_CLUE", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
}
function revealLetter() {
if (!activeEntry || !activeCellKey || !settings.allowHints) return;
const index = activeEntry.cellKeys.indexOf(activeCellKey);
setAnswers((current) => ({ ...current, [activeCellKey]: activeEntry.answer[index] }));
setRevealedCells((current) => new Set(current).add(activeCellKey));
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
setActions((current) => [...current, { type: "REVEAL_LETTER", cellKey: activeCellKey, elapsedMs: Date.now() - startedAt.current }]);
moveWithinEntry(1);
}
function revealWord() {
if (!activeEntry || !settings.allowHints) return;
setAnswers((current) => ({ ...current, ...Object.fromEntries(activeEntry.cellKeys.map((cellKey, index) => [cellKey, activeEntry.answer[index]])) }));
setRevealedCells((current) => new Set([...current, ...activeEntry.cellKeys]));
setCheckedWrong((current) => { const next = new Set(current); activeEntry.cellKeys.forEach((cellKey) => next.delete(cellKey)); return next; });
setActions((current) => [...current, { type: "REVEAL_WORD", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
setFeedback(`${activeEntry.number} ${activeEntry.direction} was revealed.`);
}
async function finish(gaveUp: boolean) {
if (saveState === "saving") return;
if (!gaveUp && !window.confirm("Submit this puzzle and reveal the results?")) return;
if (gaveUp && !window.confirm("Give up and reveal every remaining answer?")) return;
setSaveState("saving");
const durationSeconds = Math.floor((Date.now() - startedAt.current) / 1000);
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "CROSSWORD",
durationSeconds,
seed,
settings,
finalAnswers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])),
actions,
gaveUp,
}),
});
const data = await response.json();
if (!response.ok || !data.results) { setSaveState("error"); setFeedback(data.error ?? "Attempt could not be saved."); return; }
setElapsedSeconds(durationSeconds);
setResult(data.results);
}
if (result) {
return <ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete worldClassName="crossword-world crossword-stage"><CrosswordResults result={result} layout={layout} answers={answers} exitHref={`/${classSlug}/arcade/crossword`} /></ArcadeGameShell>;
}
const activeKeys = new Set(activeEntry?.cellKeys ?? []);
const visibleClues = orderedEntries.filter((entry) => entry.direction === clueTab);
const cellSize = Math.round(34 * zoom);
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete={false} worldClassName="crossword-world crossword-stage">
<input ref={mobileInput} value="" onKeyDown={handleKeyDown} onChange={(event) => writeLetter(event.target.value)} className="crossword-mobile-input" aria-label="Type crossword letter" autoCapitalize="characters" inputMode="text" />
<section className="crossword-active-clue mb-4 rounded-2xl p-4" aria-live="polite"><span>{activeEntry?.number} {activeEntry?.direction}</span><strong>{alternateClues.has(activeEntry?.id ?? "") ? activeEntry?.alternateClue : activeEntry?.clue}</strong><small>{feedback}</small></section>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div>
<div className="crossword-toolbar mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl p-2"><div className="flex gap-1"><button onClick={() => setZoom((value) => Math.max(.7, value - .1))} aria-label="Zoom out"></button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom((value) => Math.min(1.5, value + .1))} aria-label="Zoom in">+</button></div><span>{layout.entries.length} placed · {layout.omittedEntries.length} omitted</span></div>
<div className="crossword-board-viewport rounded-2xl" onKeyDown={handleKeyDown} tabIndex={0} aria-label="Crossword grid">
<div className="crossword-board" style={{ "--crossword-cell": `${cellSize}px`, gridTemplateColumns: `repeat(${layout.columns}, var(--crossword-cell))`, gridTemplateRows: `repeat(${layout.rows}, var(--crossword-cell))` } as CSSProperties}>
{layout.cells.map((cell) => <button key={cell.key} onClick={() => selectCell(cell.key)} aria-label={`Row ${cell.row + 1}, column ${cell.column + 1}${answers[cell.key] ? `, ${answers[cell.key]}` : ""}`} className={`crossword-cell ${activeKeys.has(cell.key) ? "is-word" : ""} ${activeCellKey === cell.key ? "is-active" : ""} ${checkedWrong.has(cell.key) ? "is-wrong" : ""} ${revealedCells.has(cell.key) ? "is-revealed" : ""}`} style={{ gridRow: cell.row + 1, gridColumn: cell.column + 1 }}><small>{cell.number}</small><strong>{answers[cell.key] ?? ""}</strong></button>)}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<button onClick={checkWord} className="crossword-secondary">Check word</button><button onClick={checkPuzzle} className="crossword-secondary">Check puzzle</button>
{settings.allowHints && <><button onClick={showAlternateClue} className="crossword-secondary">Alternate clue</button><button onClick={revealLetter} className="crossword-secondary">Reveal letter</button><button onClick={revealWord} className="crossword-secondary">Reveal word</button></>}
</div>
<div className="mt-4 grid grid-cols-2 gap-3"><button onClick={() => void finish(true)} className="crossword-secondary min-h-12">Give up</button><button onClick={() => void finish(false)} className="crossword-primary min-h-12 rounded-xl font-extrabold">Submit puzzle</button></div>
{saveState === "error" && <p className="mt-2 text-sm text-error" role="alert">The attempt could not be saved. Your puzzle is still open.</p>}
</div>
<aside className="crossword-clues rounded-2xl p-4">
<div className="grid grid-cols-2 gap-2">{(["across", "down"] as const).map((tab) => <button key={tab} aria-pressed={clueTab === tab} onClick={() => setClueTab(tab)} className={clueTab === tab ? "is-active" : ""}>{tab}</button>)}</div>
<ol className="mt-4 space-y-2">{visibleClues.map((entry) => <li key={entry.id}><button onClick={() => selectEntry(entry)} className={entry.id === activeEntryId ? "is-active" : ""}><b>{entry.number}</b><span>{alternateClues.has(entry.id) ? entry.alternateClue : entry.clue}</span></button></li>)}</ol>
</aside>
</div>
</ArcadeGameShell>
);
}
function CrosswordResults({ result, layout, answers, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record<string, string>; exitHref: string }) {
const ordered = result.entries.filter((entry) => !entry.omitted).sort((left, right) => {
const rank = (entry: typeof left) => !entry.correct ? 0 : entry.revealedWord || entry.revealedLetters > 0 || entry.alternateClueUsed ? 1 : 2;
return rank(left) - rank(right);
});
return <div className="crossword-results">
<section className="crossword-results-hero rounded-3xl p-6 sm:p-8"><p className="crossword-kicker">Final edition</p><h2 className="editorial-title mt-1 text-4xl">{result.outcome === "GAVE_UP" ? "Answers revealed" : "Puzzle submitted"}</h2><div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Hints", result.hintsUsed], ["Words", result.placedCount]].map(([label, value]) => <div key={label} className="rounded-xl p-3"><small>{label}</small><strong>{value}</strong></div>)}</div></section>
<FinalCrosswordBoard layout={layout} answers={answers} />
<div className="mt-5 space-y-3">{ordered.map((entry) => <article key={entry.entryId} className={`crossword-review rounded-2xl p-4 ${entry.correct ? "is-correct" : "is-incorrect"}`}><p className="text-xs font-extrabold uppercase tracking-wide">{entry.revealedWord || entry.revealedLetters ? "Assisted" : entry.correct ? "Correct" : "Incorrect"}</p><h3 className="mt-1 text-lg font-extrabold">{entry.answer}</h3><p className="mt-1 text-sm"><strong>Clue:</strong> {entry.clue}</p><p className="mt-1 text-sm"><strong>Your answer:</strong> {entry.playerAnswer || "No answer"}</p><p className="mt-3 text-sm leading-6">{entry.explanation}</p></article>)}</div>
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={() => window.location.reload()} className="crossword-primary min-h-12 rounded-xl font-extrabold">New layout</button><a href={exitHref} className="crossword-secondary flex min-h-12 items-center justify-center">Back to banks</a></div>
</div>;
}
function FinalCrosswordBoard({ layout, answers }: { layout: CrosswordLayout; answers: Record<string, string> }) {
const cellSize = Math.max(18, Math.min(30, Math.floor(640 / Math.max(layout.rows, layout.columns))));
return <section className="crossword-final-board mt-5 rounded-3xl p-4 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-3"><div><p className="crossword-kicker">Completed grid</p><h3 className="editorial-title mt-1 text-2xl">Your final board</h3></div><div className="crossword-final-legend"><span><i className="is-correct" />Correct</span><span><i className="is-incorrect" />Incorrect or blank</span></div></div>
<div className="crossword-final-viewport mt-4">
<div className="crossword-final-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>
{layout.cells.map((cell) => {
const correct = answers[cell.key] === cell.answer;
return <div key={cell.key} className={`crossword-final-cell ${correct ? "is-correct" : "is-incorrect"}`} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} aria-label={`${cell.answer}, ${correct ? "correct" : "incorrect or blank"}`}><small>{cell.number}</small><strong>{cell.answer}</strong></div>;
})}
</div>
</div>
</section>;
}

View file

@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
{ value: "mini", label: "Mini", note: "Up to 15 words" },
{ value: "standard", label: "Standard", note: "Up to 30 words" },
{ value: "large", label: "Large", note: "Up to 50 words" },
{ value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
];
export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
const router = useRouter();
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
const [showImport, setShowImport] = useState(false);
const [size, setSize] = useState<CrosswordSize>("standard");
const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
const [previewLayout, setPreviewLayout] = useState<CrosswordLayout | null>(null);
const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
useEffect(() => {
if (!selected) return;
fetch(`/api/arcade/packs/${selected.id}/attempts`)
.then((response) => response.ok ? response.json() : [])
.then(setAttempts)
.finally(() => setAttemptsLoading(false));
}, [selected]);
useEffect(() => {
if (!selected) return;
let current = true;
fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
.then((response) => response.ok ? response.json() : null)
.then((layout) => { if (current) setPreviewLayout(layout); })
.finally(() => { if (current) setPreviewLoading(false); });
return () => { current = false; };
}, [selected, size]);
function selectPack(pack: ArcadePackSummary) {
setSelectedId(pack.id);
setInstantCheck(pack.defaultInstantCheck);
setAllowHints(pack.defaultAllowHints);
setAttempts([]);
setAttemptsLoading(true);
setPreviewLayout(null);
setPreviewLoading(true);
}
function selectSize(nextSize: CrosswordSize) {
setSize(nextSize);
setPreviewLayout(null);
setPreviewLoading(true);
}
async function renamePack(pack: ArcadePackSummary) {
const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
if (!name || name === pack.name) return;
await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
router.refresh();
}
async function deletePack(pack: ArcadePackSummary) {
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
if (selectedId === pack.id) setSelectedId("");
router.refresh();
}
const playHref = selected
? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
: "#";
return (
<div className="crossword-world crossword-hub p-1 pb-20 sm:p-3">
<section className="crossword-hero mb-7 rounded-3xl p-5 sm:flex sm:items-end sm:justify-between sm:p-7">
<div>
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary"> Back to Arcade</Link>
<p className="crossword-kicker">The evening edition</p>
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Crossword</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a terminology bank, set the edition size, and work the clues at your own pace.</p>
</div>
<button onClick={() => setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank</button>
</section>
{initialPacks.length === 0 ? (
<section className="crossword-paper rounded-3xl px-6 py-16 text-center">
<div className="mx-auto mb-5 grid w-24 grid-cols-5 gap-1" aria-hidden>{Array.from({ length: 25 }, (_, index) => <i key={index} className={index % 3 === 0 ? "bg-[#2d2117]" : "bg-[#fff8e6]"} />)}</div>
<h3 className="editorial-title text-3xl text-text-heading">Print your first edition</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Import one JSON object containing exactly 80 clue-and-answer entries.</p>
<button onClick={() => setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON</button>
</section>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(22rem,.9fr)]">
<section>
<h3 className="crossword-section-label">Puzzle banks</h3>
<div className="space-y-3">
{initialPacks.map((pack) => {
const active = pack.id === effectiveSelectedId;
return <article key={pack.id} className={`crossword-pack rounded-2xl border p-4 ${active ? "is-active" : ""}`}>
<button onClick={() => selectPack(pack)} className="w-full text-left">
<div className="flex items-start justify-between gap-3"><div><h4 className="text-xl font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span>80 entries</span><span>Best {pack.bestScore ?? "—"}</span><span>Latest {pack.latestAttempt?.score ?? "—"}</span></div>
</button>
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
</article>;
})}
</div>
</section>
<aside className="crossword-setup h-fit rounded-3xl p-5 lg:sticky lg:top-6">
<h3 className="editorial-title text-2xl text-text-heading">Choose an edition</h3>
{selected ? <>
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
<fieldset className="mt-5"><legend className="crossword-section-label">Board size</legend><div className="grid grid-cols-2 gap-2">{SIZES.map((option) => <button type="button" key={option.value} aria-pressed={size === option.value} onClick={() => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}><strong className="block text-sm">{option.label}</strong><span className="text-xs">{option.note}</span></button>)}</div></fieldset>
<CrosswordBoardPreview layout={previewLayout} loading={previewLoading} />
<div className="mt-5 space-y-2">
<label className="crossword-option"><input type="checkbox" checked={instantCheck} onChange={(event) => setInstantCheck(event.target.checked)} /><span><strong>Instant word checks</strong><small>Check only after a word is filled.</small></span></label>
<label className="crossword-option"><input type="checkbox" checked={allowHints} onChange={(event) => setAllowHints(event.target.checked)} /><span><strong>Allow hints</strong><small>Alternate clues and reveals stay available.</small></span></label>
</div>
<div className="mt-5 flex justify-between border-y border-border-light py-3 text-sm"><span className="text-text-secondary">Target words</span><strong>{CROSSWORD_SIZE_TARGETS[size]}</strong></div>
<Link href={playHref} className="crossword-primary mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Open the puzzle</Link>
<div className="mt-7 border-t border-border-light pt-5"><h4 className="crossword-section-label">Recent editions</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><strong>{attempt.score}/{attempt.maxScore}</strong><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure a puzzle.</p>}
</aside>
</div>
)}
{showImport && <ArcadeImportModal classId={classId} gameType="crossword" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
</div>
);
}
function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
return <section className="crossword-board-preview mt-4" aria-label="Selected size board preview" aria-busy={loading}>
<div className="crossword-preview-heading"><span>Layout preview</span>{layout && <small>{layout.entries.length} words · {layout.columns} × {layout.rows}</small>}</div>
<div className="crossword-preview-canvas">
{loading ? <div className="crossword-preview-loading">Composing puzzle</div> : layout ? <div className="crossword-preview-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>{layout.cells.map((cell) => <i key={cell.key} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} />)}</div> : <p>Preview unavailable</p>}
</div>
</section>;
}

View file

@ -1,5 +1,7 @@
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
export const ARCADE_RENDERERS = {
connections: ConnectionsGame,
crossword: CrosswordGame,
} as const;

View file

@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" }) {
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);

View file

@ -11,8 +11,8 @@ export const ARCADE_GAMES = [
key: "crossword",
name: "Crossword",
path: "crossword",
available: false,
estimatedMinutes: "Coming soon",
available: true,
estimatedMinutes: "1045 min",
description: "Turn course terminology into an interlocking word puzzle.",
},
{

View file

@ -0,0 +1,5 @@
export class ArcadeImportError extends Error {
constructor(message: string, public readonly details: string[] = []) {
super(message);
}
}

View file

@ -1,4 +1,5 @@
import { parseAndRepairJson } from "@/lib/jsonRepair";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeImportPreview,
@ -7,11 +8,7 @@ import type {
NormalizedConnectionsPack,
} from "@/types/arcade";
export class ArcadeImportError extends Error {
constructor(message: string, public readonly details: string[] = []) {
super(message);
}
}
export { ArcadeImportError } from "@/lib/arcade/arcadeImport";
function clean(value: string) {
return value.trim().replace(/\s+/g, " ");
@ -59,7 +56,7 @@ function findWarnings(pack: NormalizedConnectionsPack) {
return [...new Set(warnings)];
}
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview {
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedConnectionsPack> {
const trimmed = rawJson.trim();
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
@ -95,7 +92,7 @@ export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchP
}
export function parseConnectionsImport(rawJson: string): {
preview: ArcadeImportPreview;
preview: ArcadeImportPreview<NormalizedConnectionsPack>;
report: ArcadeValidationReport;
} {
const trimmed = rawJson.trim();
@ -165,6 +162,7 @@ export function parseConnectionsImport(rawJson: string): {
wasRepaired: repaired.wasRepaired,
warnings,
normalized,
source: repaired.data,
},
};
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { replayCrosswordAttempt } from "@/lib/arcade/crosswordAttempt";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
const settings = { size: "mini" as const, instantCheck: false, allowHints: true };
describe("Crossword attempt replay", () => {
it("scores a perfect unassisted puzzle on the server", () => {
const pack = crosswordTestPack();
const layout = generateCrosswordLayout(pack, "mini", "perfect");
const answers = Object.fromEntries(layout.entries.map((entry) => [entry.id, entry.answer]));
const result = replayCrosswordAttempt(pack, "perfect", settings, answers, [], 120, false);
expect(result.score).toBe(result.maxScore);
expect(result.accuracy).toBe(1);
expect(result.placedCount + result.omittedCount).toBe(80);
});
it("applies check and reveal penalties and rejects tampered actions", () => {
const pack = crosswordTestPack();
const layout = generateCrosswordLayout(pack, "mini", "assisted");
const entry = layout.entries[0];
const answers = Object.fromEntries(layout.entries.map((item) => [item.id, item.answer]));
const result = replayCrosswordAttempt(pack, "assisted", settings, answers, [
{ type: "CHECK_WORD", entryId: entry.id, value: "WRONG", elapsedMs: 1000 },
{ type: "REVEAL_LETTER", cellKey: entry.cellKeys[0], elapsedMs: 2000 },
{ type: "ALTERNATE_CLUE", entryId: entry.id, elapsedMs: 3000 },
], 30, false);
const assistedWords = layout.cells.find((cell) => cell.key === entry.cellKeys[0])?.entryIds.length ?? 1;
expect(result.score).toBe(result.maxScore - 5 - assistedWords * 10);
expect(result.hintsUsed).toBe(2);
expect(() => replayCrosswordAttempt(pack, "assisted", settings, answers, [{ type: "REVEAL_LETTER", cellKey: "999:999", elapsedMs: 1 }], 1, false)).toThrow("invalid letter reveal");
});
});

View file

@ -0,0 +1,116 @@
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import type {
CrosswordAction,
CrosswordRoundResult,
CrosswordSessionSettings,
NormalizedCrosswordPack,
} from "@/types/arcade";
function answerKey(value: string) {
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
}
export function replayCrosswordAttempt(
pack: NormalizedCrosswordPack,
seed: string,
settings: CrosswordSessionSettings,
finalAnswers: Record<string, string>,
actions: CrosswordAction[],
durationSeconds: number,
gaveUp: boolean
): CrosswordRoundResult {
const layout = generateCrosswordLayout(pack, settings.size, seed);
const entryById = new Map(layout.entries.map((entry) => [entry.id, entry]));
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
const unknownAnswerId = Object.keys(finalAnswers).find((id) => !entryById.has(id));
if (unknownAnswerId) throw new Error("An attempt contains an answer for an entry outside this layout.");
const revealedCells = new Set<string>();
const revealedWords = new Set<string>();
const alternateClues = new Set<string>();
let incorrectChecks = 0;
let hintsUsed = 0;
for (const action of actions) {
if (!Number.isInteger(action.elapsedMs) || action.elapsedMs < 0 || action.elapsedMs > 86_400_000) {
throw new Error("An attempt contains an invalid action timestamp.");
}
if (action.type === "REVEAL_LETTER") {
if (!settings.allowHints || !cellByKey.has(action.cellKey)) throw new Error("An attempt contains an invalid letter reveal.");
if (!revealedCells.has(action.cellKey)) hintsUsed += 1;
revealedCells.add(action.cellKey);
continue;
}
if (action.type === "CHECK_PUZZLE") {
if (Object.keys(action.answers).some((id) => !entryById.has(id))) throw new Error("A puzzle check references an unknown entry.");
continue;
}
const entry = entryById.get(action.entryId);
if (!entry) throw new Error("An attempt action references an unknown entry.");
if (action.type === "CHECK_WORD") {
if (answerKey(action.value) !== entry.answer) incorrectChecks += 1;
} else if (action.type === "ALTERNATE_CLUE") {
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
if (!alternateClues.has(entry.id)) hintsUsed += 1;
alternateClues.add(entry.id);
} else if (action.type === "REVEAL_WORD") {
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
if (!revealedWords.has(entry.id)) hintsUsed += 1;
revealedWords.add(entry.id);
entry.cellKeys.forEach((cellKey) => revealedCells.add(cellKey));
}
}
let score = 0;
let accurateWords = 0;
const placedResults = layout.entries.map((entry) => {
const playerAnswer = answerKey(finalAnswers[entry.id] ?? "");
const correct = playerAnswer === entry.answer || revealedWords.has(entry.id);
const revealedLetterCount = entry.cellKeys.filter((cellKey) => revealedCells.has(cellKey)).length;
if (correct && !revealedWords.has(entry.id)) {
accurateWords += 1;
score += Math.max(20, 100 - revealedLetterCount * 10);
}
return {
entryId: entry.id,
clue: entry.clue,
answer: entry.displayAnswer,
playerAnswer: finalAnswers[entry.id] ?? "",
explanation: entry.explanation,
correct,
omitted: false,
revealedLetters: revealedLetterCount,
revealedWord: revealedWords.has(entry.id),
alternateClueUsed: alternateClues.has(entry.id),
};
});
score = Math.max(0, score - incorrectChecks * 5);
return {
outcome: gaveUp ? "GAVE_UP" : "COMPLETED",
score,
maxScore: layout.entries.length * 100,
accuracy: layout.entries.length === 0 ? 0 : accurateWords / layout.entries.length,
durationSeconds,
mistakes: incorrectChecks,
hintsUsed,
size: settings.size,
placedCount: layout.entries.length,
omittedCount: layout.omittedEntries.length,
entries: [
...placedResults,
...layout.omittedEntries.map((entry) => ({
entryId: entry.id,
clue: entry.clue,
answer: entry.displayAnswer,
playerAnswer: "",
explanation: entry.explanation,
correct: false,
omitted: true,
revealedLetters: 0,
revealedWord: false,
alternateClueUsed: false,
})),
],
};
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
describe("Crossword grid engine", () => {
it("is deterministic and respects the selected target", () => {
const pack = crosswordTestPack();
const first = generateCrosswordLayout(pack, "mini", "same-seed");
const second = generateCrosswordLayout(pack, "mini", "same-seed");
expect(second).toEqual(first);
expect(first.entries).toHaveLength(15);
expect(first.entries.length).toBeLessThanOrEqual(first.targetCount);
expect(first.entries.length + first.omittedEntries.length).toBe(80);
});
it("creates matching cells, shared intersections, and sequential clue numbers", () => {
const layout = generateCrosswordLayout(crosswordTestPack(), "standard", "grid-rules");
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
expect(layout.entries.length).toBeGreaterThanOrEqual(15);
expect(layout.cells.some((cell) => cell.entryIds.length > 1)).toBe(true);
for (const entry of layout.entries) {
expect(entry.cellKeys.map((key, index) => cellByKey.get(key)?.answer === entry.answer[index]).every(Boolean)).toBe(true);
expect(entry.number).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,266 @@
import { deterministicShuffle } from "@/lib/arcade/connectionsEngine";
import type {
CrosswordCell,
CrosswordLayout,
CrosswordPlacedEntry,
CrosswordSize,
NormalizedCrosswordEntry,
NormalizedCrosswordPack,
} from "@/types/arcade";
export const CROSSWORD_SIZE_TARGETS: Record<CrosswordSize, number> = {
mini: 15,
standard: 30,
large: 50,
"extra-large": 80,
};
type Direction = "across" | "down";
interface WorkingCell {
answer: string;
directions: Set<Direction>;
entryIds: string[];
}
interface WorkingEntry {
entry: NormalizedCrosswordEntry;
direction: Direction;
row: number;
column: number;
cellKeys: string[];
}
interface Candidate {
entry: NormalizedCrosswordEntry;
direction: Direction;
row: number;
column: number;
intersections: number;
score: number;
}
function key(row: number, column: number) {
return `${row}:${column}`;
}
function coordinates(cellKey: string) {
const [row, column] = cellKey.split(":").map(Number);
return { row, column };
}
function bounds(cells: Map<string, WorkingCell>) {
const points = [...cells.keys()].map(coordinates);
const rows = points.map((point) => point.row);
const columns = points.map((point) => point.column);
return {
minRow: Math.min(...rows),
maxRow: Math.max(...rows),
minColumn: Math.min(...columns),
maxColumn: Math.max(...columns),
};
}
function placementCells(answer: string, row: number, column: number, direction: Direction) {
return Array.from(answer, (letter, index) => ({
letter,
row: row + (direction === "down" ? index : 0),
column: column + (direction === "across" ? index : 0),
}));
}
function evaluatePlacement(
entry: NormalizedCrosswordEntry,
row: number,
column: number,
direction: Direction,
cells: Map<string, WorkingCell>
): Candidate | null {
const positions = placementCells(entry.answer, row, column, direction);
const before = direction === "across" ? key(row, column - 1) : key(row - 1, column);
const afterPosition = positions[positions.length - 1];
const after = direction === "across"
? key(afterPosition.row, afterPosition.column + 1)
: key(afterPosition.row + 1, afterPosition.column);
if (cells.has(before) || cells.has(after)) return null;
let intersections = 0;
for (const position of positions) {
const cellKey = key(position.row, position.column);
const existing = cells.get(cellKey);
if (existing) {
if (existing.answer !== position.letter || existing.directions.has(direction)) return null;
intersections += 1;
continue;
}
const neighbors = direction === "across"
? [key(position.row - 1, position.column), key(position.row + 1, position.column)]
: [key(position.row, position.column - 1), key(position.row, position.column + 1)];
if (neighbors.some((neighbor) => cells.has(neighbor))) return null;
}
if (intersections === 0) return null;
const allKeys = [...cells.keys(), ...positions.map((position) => key(position.row, position.column))];
const allPoints = allKeys.map(coordinates);
const minRow = Math.min(...allPoints.map((point) => point.row));
const maxRow = Math.max(...allPoints.map((point) => point.row));
const minColumn = Math.min(...allPoints.map((point) => point.column));
const maxColumn = Math.max(...allPoints.map((point) => point.column));
const height = maxRow - minRow + 1;
const width = maxColumn - minColumn + 1;
const score = intersections * 1000 - height * width * 2 - Math.abs(height - width) * 8;
return { entry, row, column, direction, intersections, score };
}
function findCandidates(entry: NormalizedCrosswordEntry, cells: Map<string, WorkingCell>) {
const candidates: Candidate[] = [];
for (const [cellKey, cell] of cells) {
const point = coordinates(cellKey);
for (let index = 0; index < entry.answer.length; index += 1) {
if (entry.answer[index] !== cell.answer) continue;
if (!cell.directions.has("across")) {
const candidate = evaluatePlacement(entry, point.row, point.column - index, "across", cells);
if (candidate) candidates.push(candidate);
}
if (!cell.directions.has("down")) {
const candidate = evaluatePlacement(entry, point.row - index, point.column, "down", cells);
if (candidate) candidates.push(candidate);
}
}
}
return candidates;
}
function addEntry(candidate: Candidate, cells: Map<string, WorkingCell>, entries: WorkingEntry[]) {
const cellKeys: string[] = [];
for (const position of placementCells(candidate.entry.answer, candidate.row, candidate.column, candidate.direction)) {
const cellKey = key(position.row, position.column);
const existing = cells.get(cellKey);
if (existing) {
existing.directions.add(candidate.direction);
existing.entryIds.push(candidate.entry.id);
} else {
cells.set(cellKey, {
answer: position.letter,
directions: new Set([candidate.direction]),
entryIds: [candidate.entry.id],
});
}
cellKeys.push(cellKey);
}
entries.push({ entry: candidate.entry, direction: candidate.direction, row: candidate.row, column: candidate.column, cellKeys });
}
function buildCandidate(pack: NormalizedCrosswordPack, targetCount: number, seed: string, pass: number) {
const order = deterministicShuffle(pack.content, `${seed}:entries:${pass}`);
const firstPool = order.slice(0, Math.min(12, order.length)).sort((a, b) => b.answer.length - a.answer.length);
const first = firstPool[pass % firstPool.length];
const cells = new Map<string, WorkingCell>();
const entries: WorkingEntry[] = [];
addEntry({ entry: first, direction: pass % 2 === 0 ? "across" : "down", row: 0, column: 0, intersections: 0, score: 0 }, cells, entries);
const remaining = order.filter((entry) => entry.id !== first.id);
while (entries.length < targetCount && remaining.length > 0) {
const candidates: Candidate[] = [];
let entriesWithCandidates = 0;
for (const entry of remaining) {
const entryCandidates = findCandidates(entry, cells);
if (entryCandidates.length === 0) continue;
entryCandidates.sort((left, right) => right.score - left.score);
candidates.push(...entryCandidates.slice(0, 3));
entriesWithCandidates += 1;
if (entriesWithCandidates >= 12) break;
}
if (candidates.length === 0) break;
candidates.sort((left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id));
const top = candidates.slice(0, Math.min(8, candidates.length));
const selected = deterministicShuffle(top, `${seed}:choice:${pass}:${entries.length}`)[0];
addEntry(selected, cells, entries);
const usedIndex = remaining.findIndex((entry) => entry.id === selected.entry.id);
remaining.splice(usedIndex, 1);
}
return { cells, entries };
}
function candidateScore(candidate: ReturnType<typeof buildCandidate>) {
const box = bounds(candidate.cells);
const height = box.maxRow - box.minRow + 1;
const width = box.maxColumn - box.minColumn + 1;
const intersections = [...candidate.cells.values()].filter((cell) => cell.directions.size > 1).length;
return candidate.entries.length * 1_000_000 + intersections * 10_000 - height * width * 10 - Math.abs(height - width) * 20;
}
function finalizeLayout(
pack: NormalizedCrosswordPack,
size: CrosswordSize,
seed: string,
candidate: ReturnType<typeof buildCandidate>
): CrosswordLayout {
const box = bounds(candidate.cells);
const offsetRow = -box.minRow;
const offsetColumn = -box.minColumn;
const starts = new Map<string, number>();
const sortedStarts = candidate.entries
.map((placed) => ({ key: key(placed.row + offsetRow, placed.column + offsetColumn), row: placed.row + offsetRow, column: placed.column + offsetColumn }))
.sort((left, right) => left.row - right.row || left.column - right.column);
for (const start of sortedStarts) {
if (!starts.has(start.key)) starts.set(start.key, starts.size + 1);
}
const entries: CrosswordPlacedEntry[] = candidate.entries.map((placed) => {
const row = placed.row + offsetRow;
const column = placed.column + offsetColumn;
return {
...placed.entry,
direction: placed.direction,
row,
column,
number: starts.get(key(row, column)) ?? 0,
cellKeys: placed.cellKeys.map((cellKey) => {
const point = coordinates(cellKey);
return key(point.row + offsetRow, point.column + offsetColumn);
}),
};
});
const placedIds = new Set(entries.map((entry) => entry.id));
const cells: CrosswordCell[] = [...candidate.cells.entries()].map(([cellKey, cell]) => {
const point = coordinates(cellKey);
const normalizedKey = key(point.row + offsetRow, point.column + offsetColumn);
return {
key: normalizedKey,
row: point.row + offsetRow,
column: point.column + offsetColumn,
answer: cell.answer,
number: starts.get(normalizedKey),
entryIds: cell.entryIds,
};
}).sort((left, right) => left.row - right.row || left.column - right.column);
return {
size,
seed,
targetCount: CROSSWORD_SIZE_TARGETS[size],
rows: box.maxRow - box.minRow + 1,
columns: box.maxColumn - box.minColumn + 1,
cells,
entries,
omittedEntries: pack.content.filter((entry) => !placedIds.has(entry.id)),
};
}
export function generateCrosswordLayout(pack: NormalizedCrosswordPack, size: CrosswordSize, seed: string) {
const targetCount = CROSSWORD_SIZE_TARGETS[size];
let best = buildCandidate(pack, targetCount, seed, 0);
let bestScore = candidateScore(best);
if (best.entries.length === targetCount) return finalizeLayout(pack, size, seed, best);
for (let pass = 1; pass < 250; pass += 1) {
const candidate = buildCandidate(pack, targetCount, seed, pass);
const score = candidateScore(candidate);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
if (best.entries.length === targetCount) break;
}
return finalizeLayout(pack, size, seed, best);
}

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { parseCrosswordImport } from "@/lib/arcade/crosswordImport";
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
function importObject() {
const pack = crosswordTestPack();
return {
...pack,
content: pack.content.map(({ answer, displayAnswer, clue, alternateClue, explanation }, index) => ({
id: `term-${index + 1}`,
answer: index === 0 ? "Study-AA Word" : answer,
displayAnswer,
clue,
alternateClue,
explanation,
})),
};
}
describe("Crossword imports", () => {
it("normalizes one valid 80-entry pack and previews every size", () => {
const result = parseCrosswordImport(JSON.stringify(importObject()));
expect(result.preview.itemCount).toBe(80);
expect(result.preview.normalized.content[0].answer).toBe("STUDYAAWORD");
expect(result.preview.layoutPreviews?.map((layout) => layout.size)).toEqual(["mini", "standard", "large", "extra-large"]);
});
it("rejects packs with fewer or more than 80 entries", () => {
const short = importObject();
short.content.pop();
expect(() => parseCrosswordImport(JSON.stringify(short))).toThrow("exactly 80 entries");
const long = importObject();
long.content.push({ ...long.content[0], id: "extra", answer: "EXTRATERM" });
expect(() => parseCrosswordImport(JSON.stringify(long))).toThrow("exactly 80 entries");
});
it("rejects arrays, normalized duplicates, digits, and unknown keys", () => {
expect(() => parseCrosswordImport(JSON.stringify([importObject()]))).toThrow("one raw Crossword JSON object");
const duplicate = importObject();
duplicate.content[1].answer = "study aa word";
expect(() => parseCrosswordImport(JSON.stringify(duplicate))).toThrow("duplicated");
const digits = importObject();
digits.content[0].answer = "TERM2";
expect(() => parseCrosswordImport(JSON.stringify(digits))).toThrow("digits");
expect(() => parseCrosswordImport(JSON.stringify({ ...importObject(), extra: true }))).toThrow("does not match schema");
});
});

View file

@ -0,0 +1,126 @@
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { CROSSWORD_SIZE_TARGETS, generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
import { parseAndRepairJson } from "@/lib/jsonRepair";
import { crosswordImportSchema } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeImportBatchPreview,
ArcadeImportPreview,
ArcadeValidationReport,
CrosswordLayoutPreview,
CrosswordSize,
NormalizedCrosswordPack,
} from "@/types/arcade";
function clean(value: string) {
return value.trim().replace(/\s+/g, " ");
}
function safeId(value: string, fallback: string) {
const id = value.trim().toLocaleLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
return id || fallback;
}
export function normalizeCrosswordAnswer(value: string) {
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
}
export function parseCrosswordImport(rawJson: string): {
preview: ArcadeImportPreview<NormalizedCrosswordPack>;
report: ArcadeValidationReport;
} {
const trimmed = rawJson.trim();
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
throw new ArcadeImportError("Paste one raw Crossword JSON object without Markdown fences, an array, or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
if (Array.isArray(repaired.data)) throw new ArcadeImportError("Crossword imports accept one pack object at a time.");
const parsed = crosswordImportSchema.safeParse(repaired.data);
if (!parsed.success) {
throw new ArcadeImportError(
"The Crossword pack does not match schema version 1 and must contain exactly 80 entries.",
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
);
}
const warnings: string[] = [];
const usedIds = new Set<string>();
const usedAnswers = new Set<string>();
const normalized: NormalizedCrosswordPack = {
schemaVersion: 1,
type: "crossword",
name: clean(parsed.data.name),
description: parsed.data.description ? clean(parsed.data.description) : undefined,
settings: { ...parsed.data.settings },
content: parsed.data.content.map((entry, index) => {
if (/\d/.test(entry.answer)) throw new ArcadeImportError(`Entry ${index + 1} uses digits, which Crossword answers do not support.`);
const answer = normalizeCrosswordAnswer(entry.answer);
if (answer.length < 3 || answer.length > 18) {
throw new ArcadeImportError(`Entry ${index + 1} must contain 3 to 18 letters after normalization.`);
}
if (usedAnswers.has(answer)) throw new ArcadeImportError(`The normalized answer “${answer}” is duplicated.`);
usedAnswers.add(answer);
const fallbackId = `entry-${index + 1}`;
const id = safeId(entry.id ?? fallbackId, fallbackId);
if (usedIds.has(id)) throw new ArcadeImportError(`Entry id “${id}” is duplicated.`);
usedIds.add(id);
if (answer !== entry.answer.trim().toUpperCase()) warnings.push(`${entry.answer}” will be placed as “${answer}”.`);
if (entry.clue.length > 180) warnings.push(`The clue for “${entry.displayAnswer ?? entry.answer}” is longer than 180 characters.`);
return {
id,
answer,
displayAnswer: clean(entry.displayAnswer ?? entry.answer),
clue: entry.clue.trim(),
alternateClue: entry.alternateClue.trim(),
explanation: entry.explanation.trim(),
};
}),
};
const sizes: CrosswordSize[] = ["mini", "standard", "large", "extra-large"];
const layoutPreviews: CrosswordLayoutPreview[] = sizes.map((size) => {
const layout = generateCrosswordLayout(normalized, size, "crossword-import-preview-v1");
return {
size,
targetCount: CROSSWORD_SIZE_TARGETS[size],
placedCount: layout.entries.length,
omittedCount: layout.omittedEntries.length,
rows: layout.rows,
columns: layout.columns,
};
});
const mini = layoutPreviews[0];
if (mini.placedCount < 15) {
throw new ArcadeImportError(
"This pack cannot form a connected 15-word Mini crossword.",
[`The best preview placed ${mini.placedCount} of 15 target entries. Use answers with more shared letters.`]
);
}
for (const layout of layoutPreviews) {
if (layout.placedCount < layout.targetCount) {
warnings.push(`${layout.size} preview placed ${layout.placedCount} of ${layout.targetCount} target words.`);
}
}
const uniqueWarnings = [...new Set(warnings)];
const report = { wasRepaired: repaired.wasRepaired, warnings: uniqueWarnings };
return {
report,
preview: {
gameType: "crossword",
name: normalized.name,
description: normalized.description,
categories: [],
itemCount: 80,
wasRepaired: repaired.wasRepaired,
warnings: uniqueWarnings,
normalized,
source: repaired.data,
layoutPreviews,
},
};
}
export function parseCrosswordImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedCrosswordPack> {
const parsed = parseCrosswordImport(rawJson);
return { packs: [parsed.preview], count: 1, wasRepaired: parsed.preview.wasRepaired };
}

View file

@ -0,0 +1,22 @@
import type { NormalizedCrosswordPack } from "@/types/arcade";
function letters(index: number) {
return `${String.fromCharCode(65 + Math.floor(index / 26))}${String.fromCharCode(65 + (index % 26))}`;
}
export function crosswordTestPack(): NormalizedCrosswordPack {
return {
schemaVersion: 1,
type: "crossword",
name: "Test Crossword",
settings: { allowInstantCheck: false, allowHints: true },
content: Array.from({ length: 80 }, (_, index) => ({
id: `entry-${index + 1}`,
answer: `STUDY${letters(index)}WORD`,
displayAnswer: `Study ${letters(index)} Word`,
clue: `Test clue ${index + 1}`,
alternateClue: `Alternate test clue ${index + 1}`,
explanation: `Explanation ${index + 1}`,
})),
};
}

View file

@ -1,4 +1,5 @@
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import { parseCrosswordImportBatch } from "@/lib/arcade/crosswordImport";
import type { ArcadeGameKey } from "@/types/arcade";
const CONNECTIONS_PROMPT = `You are generating a Connections study game as strict JSON.
@ -18,15 +19,39 @@ Rules:
Material:
[paste your notes or lecture content here]`;
const CROSSWORD_PROMPT = `You are generating one Crossword study game as strict JSON.
Return exactly one JSON object with no Markdown fence or commentary.
Use this schema:
{"schemaVersion":1,"type":"crossword","name":"...","description":"...","settings":{"allowInstantCheck":false,"allowHints":true},"content":[{"id":"entry-1","answer":"...","displayAnswer":"...","clue":"...","alternateClue":"...","explanation":"..."}]}
Rules:
- Generate exactly 80 entries in the content array.
- Every answer must be unique after spaces, punctuation, hyphens, apostrophes, and capitalization are removed.
- Answers must contain 3 to 18 letters after normalization. Do not use digits.
- Prefer terminology with shared letters so the application can construct a dense connected crossword.
- Each clue must uniquely identify its answer without repeating the answer.
- Provide a genuinely useful alternate clue and a concise educational explanation for every entry.
- Use displayAnswer to preserve spaces, punctuation, or natural capitalization when needed.
- Base all content strictly on the study material below.
Material:
[paste your notes or lecture content here]`;
export const ARCADE_SERVER_REGISTRY = {
connections: {
schemaVersion: 1,
preview: parseConnectionsImportBatch,
defaultInstructions: CONNECTIONS_PROMPT,
},
crossword: {
schemaVersion: 1,
preview: parseCrosswordImportBatch,
defaultInstructions: CROSSWORD_PROMPT,
},
} satisfies Record<ArcadeGameKey, {
schemaVersion: number;
preview: typeof parseConnectionsImportBatch;
preview: (rawJson: string) => unknown;
defaultInstructions: string;
}>;

View file

@ -27,12 +27,35 @@ export const connectionsImportSchema = z.object({
content: z.array(connectionGroupSchema).length(4),
}).strict();
const crosswordEntrySchema = z.object({
id: z.string().trim().min(1).max(100).optional(),
answer: z.string().trim().min(1).max(80),
displayAnswer: z.string().trim().min(1).max(80).optional(),
clue: z.string().trim().min(1).max(500),
alternateClue: z.string().trim().min(1).max(500),
explanation: z.string().trim().min(1).max(1500),
}).strict();
export const crosswordImportSchema = z.object({
schemaVersion: z.literal(1),
type: z.literal("crossword"),
name: z.string().trim().min(1).max(150),
description: z.string().trim().max(500).optional(),
settings: z.object({
allowInstantCheck: z.boolean(),
allowHints: z.boolean(),
}).strict(),
content: z.array(crosswordEntrySchema).length(80),
}).strict();
export const arcadePreviewRequestSchema = z.object({
gameType: z.literal("connections"),
gameType: z.union([z.literal("connections"), z.literal("crossword")]),
rawJson: z.string().min(1),
});
export const arcadePackCreateSchema = arcadePreviewRequestSchema.extend({
export const arcadePackCreateSchema = z.object({
gameType: z.union([z.literal("connections"), z.literal("crossword")]),
rawJson: z.string().min(1),
classId: z.string().min(1),
name: z.string().trim().min(1).max(150).optional(),
names: z.array(z.string().trim().min(1).max(150)).min(1).max(10).optional(),
@ -57,5 +80,28 @@ export const arcadeAttemptCreateSchema = z.object({
}).strict()).max(50),
});
const crosswordActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("CHECK_WORD"), entryId: z.string().min(1), value: z.string(), elapsedMs: z.number().int() }).strict(),
z.object({ type: z.literal("CHECK_PUZZLE"), answers: z.record(z.string(), z.string()), elapsedMs: z.number().int() }).strict(),
z.object({ type: z.literal("ALTERNATE_CLUE"), entryId: z.string().min(1), elapsedMs: z.number().int() }).strict(),
z.object({ type: z.literal("REVEAL_LETTER"), cellKey: z.string().regex(/^-?\d+:-?\d+$/), elapsedMs: z.number().int() }).strict(),
z.object({ type: z.literal("REVEAL_WORD"), entryId: z.string().min(1), elapsedMs: z.number().int() }).strict(),
]);
export const crosswordAttemptCreateSchema = z.object({
mode: z.literal("CROSSWORD").default("CROSSWORD"),
durationSeconds: z.number().int().min(0).max(86400),
seed: z.string().min(1).max(100),
settings: z.object({
size: z.union([z.literal("mini"), z.literal("standard"), z.literal("large"), z.literal("extra-large")]),
instantCheck: z.boolean(),
allowHints: z.boolean(),
}).strict(),
finalAnswers: z.record(z.string(), z.string()),
actions: z.array(crosswordActionSchema).max(1000),
gaveUp: z.boolean(),
}).strict();
export type ConnectionsImportInput = z.infer<typeof connectionsImportSchema>;
export type ArcadeAttemptCreateInput = z.infer<typeof arcadeAttemptCreateSchema>;
export type CrosswordAttemptCreateInput = z.infer<typeof crosswordAttemptCreateSchema>;

View file

@ -1,12 +1,16 @@
import { prisma } from "@/lib/db";
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import { replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { ArcadeAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
import { parseCrosswordImportBatch } from "@/lib/arcade/crosswordImport";
import { replayCrosswordAttempt } from "@/lib/arcade/crosswordAttempt";
import type { ArcadeAttemptCreateInput, CrosswordAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeAttemptSummary,
ArcadeGameKey,
ArcadePackSummary,
NormalizedArcadePack,
NormalizedConnectionsPack,
NormalizedCrosswordPack,
} from "@/types/arcade";
function attemptSummary(attempt: {
@ -31,8 +35,12 @@ function attemptSummary(attempt: {
};
}
export function previewArcadeImport(rawJson: string) {
return parseConnectionsImportBatch(rawJson);
function parseImport(gameType: ArcadeGameKey, rawJson: string) {
return gameType === "connections" ? parseConnectionsImportBatch(rawJson) : parseCrosswordImportBatch(rawJson);
}
export function previewArcadeImport(gameType: ArcadeGameKey, rawJson: string) {
return parseImport(gameType, rawJson);
}
export async function createArcadePacks(input: {
@ -46,7 +54,7 @@ export async function createArcadePacks(input: {
const classItem = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!classItem) throw new Error("Class not found");
const batch = parseConnectionsImportBatch(input.rawJson);
const batch = parseImport(input.gameType, input.rawJson);
if (input.names && input.names.length !== batch.count) {
throw new Error("A name is required for every imported pack.");
}
@ -67,7 +75,7 @@ export async function createArcadePacks(input: {
name: input.names?.[index]?.trim() || (batch.count === 1 ? input.name?.trim() : undefined) || preview.name,
description: preview.description,
schemaVersion: preview.normalized.schemaVersion,
sourceJson: JSON.stringify(preview.normalized),
sourceJson: JSON.stringify(preview.source),
normalizedJson: JSON.stringify(preview.normalized),
validationReportJson: JSON.stringify({ wasRepaired: preview.wasRepaired, warnings: preview.warnings }),
sortOrder: (maxOrder._max.sortOrder ?? -1) + index + 1,
@ -85,7 +93,8 @@ export async function listArcadePacks(classId: string, gameType: ArcadeGameKey):
include: { attempts: { orderBy: { completedAt: "desc" } } },
});
return packs.map((pack) => {
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
const normalized = JSON.parse(pack.normalizedJson) as NormalizedArcadePack;
const isConnections = normalized.type === "connections";
return {
id: pack.id,
classId: pack.classId,
@ -93,8 +102,10 @@ export async function listArcadePacks(classId: string, gameType: ArcadeGameKey):
name: pack.name,
description: pack.description,
schemaVersion: pack.schemaVersion,
itemCount: normalized.content.length * 4,
defaultAllowedMistakes: normalized.settings.allowedMistakes,
itemCount: isConnections ? normalized.content.length * 4 : normalized.content.length,
defaultAllowedMistakes: isConnections ? normalized.settings.allowedMistakes : 4,
defaultInstantCheck: isConnections ? false : normalized.settings.allowInstantCheck,
defaultAllowHints: isConnections ? true : normalized.settings.allowHints,
bestScore: pack.attempts.length ? Math.max(...pack.attempts.map((attempt) => attempt.score)) : null,
latestAttempt: pack.attempts[0] ? attemptSummary(pack.attempts[0]) : null,
};
@ -110,7 +121,7 @@ export async function getArcadePack(id: string) {
return {
...pack,
gameType: pack.gameType as ArcadeGameKey,
normalized: JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack,
normalized: JSON.parse(pack.normalizedJson) as NormalizedArcadePack,
validationReport: JSON.parse(pack.validationReportJson),
};
}
@ -141,6 +152,7 @@ export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAtt
const pack = await prisma.arcadePack.findUnique({ where: { id: arcadePackId } });
if (!pack) return null;
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
if (normalized.type !== "connections") throw new Error("This attempt does not match the pack game type.");
const replay = replayConnectionsAttempt(
normalized,
input.submissions,
@ -174,3 +186,43 @@ export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAtt
return attemptSummary({ ...attempt, resultsJson: attempt.resultsJson });
});
}
export async function createCrosswordAttempt(arcadePackId: string, input: CrosswordAttemptCreateInput) {
const pack = await prisma.arcadePack.findUnique({ where: { id: arcadePackId } });
if (!pack) return null;
const normalized = JSON.parse(pack.normalizedJson) as NormalizedCrosswordPack;
if (normalized.type !== "crossword") throw new Error("This attempt does not match the pack game type.");
const result = replayCrosswordAttempt(
normalized,
input.seed,
input.settings,
input.finalAnswers,
input.actions,
input.durationSeconds,
input.gaveUp
);
const correctWords = result.entries.filter((entry) => !entry.omitted && entry.correct && !entry.revealedWord).length;
return prisma.$transaction(async (transaction) => {
const attempt = await transaction.arcadeAttempt.create({
data: {
arcadePackId,
mode: input.mode,
score: result.score,
maxScore: result.maxScore,
accuracy: result.accuracy,
durationSeconds: result.durationSeconds,
mistakes: result.mistakes,
hintsUsed: result.hintsUsed,
settingsJson: JSON.stringify(input.settings),
resultsJson: JSON.stringify(result),
seed: input.seed,
},
});
if (correctWords > 0) {
await transaction.studyActivity.createMany({
data: Array.from({ length: correctWords }, () => ({ type: "ARCADE_WORD" })),
});
}
return attemptSummary({ ...attempt, resultsJson: attempt.resultsJson });
});
}

View file

@ -1,7 +1,7 @@
import { prisma } from "@/lib/db";
import { ARCADE_SERVER_REGISTRY } from "@/lib/arcade/registry";
export type LlmInstructionType = "flashcards" | "quizzes" | "connections";
export type LlmInstructionType = "flashcards" | "quizzes" | "connections" | "crossword";
const DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS = `You are generating study materials in a strict JSON format for import into a
personal study app. The overall output must be raw JSON with no wrapping
@ -54,7 +54,8 @@ Material:
function instructionConfig(type: LlmInstructionType) {
if (type === "flashcards") return { key: "llmInstructionsFlashcards", value: DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS };
if (type === "quizzes") return { key: "llmInstructionsQuizzes", value: DEFAULT_LLM_INSTRUCTIONS_QUIZZES };
return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
if (type === "connections") return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
return { key: "llmInstructionsCrossword", value: ARCADE_SERVER_REGISTRY.crossword.defaultInstructions };
}
export async function getLlmInstructions(type: LlmInstructionType): Promise<string> {

View file

@ -1,4 +1,5 @@
export type ArcadeGameKey = "connections";
export type ArcadeGameKey = "connections" | "crossword";
export type CrosswordSize = "mini" | "standard" | "large" | "extra-large";
export interface ConnectionsImportGroup {
id?: string;
@ -42,12 +43,65 @@ export interface NormalizedConnectionsPack {
content: NormalizedConnectionsGroup[];
}
export interface CrosswordImportEntry {
id?: string;
answer: string;
displayAnswer?: string;
clue: string;
alternateClue: string;
explanation: string;
}
export interface CrosswordImportData {
schemaVersion: 1;
type: "crossword";
name: string;
description?: string;
settings: {
allowInstantCheck: boolean;
allowHints: boolean;
};
content: CrosswordImportEntry[];
}
export interface NormalizedCrosswordEntry {
id: string;
answer: string;
displayAnswer: string;
clue: string;
alternateClue: string;
explanation: string;
}
export interface NormalizedCrosswordPack {
schemaVersion: 1;
type: "crossword";
name: string;
description?: string;
settings: {
allowInstantCheck: boolean;
allowHints: boolean;
};
content: NormalizedCrosswordEntry[];
}
export type NormalizedArcadePack = NormalizedConnectionsPack | NormalizedCrosswordPack;
export interface CrosswordLayoutPreview {
size: CrosswordSize;
targetCount: number;
placedCount: number;
omittedCount: number;
rows: number;
columns: number;
}
export interface ArcadeValidationReport {
wasRepaired: boolean;
warnings: string[];
}
export interface ArcadeImportPreview {
export interface ArcadeImportPreview<TPack extends NormalizedArcadePack = NormalizedArcadePack> {
gameType: ArcadeGameKey;
name: string;
description?: string;
@ -55,11 +109,13 @@ export interface ArcadeImportPreview {
itemCount: number;
wasRepaired: boolean;
warnings: string[];
normalized: NormalizedConnectionsPack;
normalized: TPack;
source: unknown;
layoutPreviews?: CrosswordLayoutPreview[];
}
export interface ArcadeImportBatchPreview {
packs: ArcadeImportPreview[];
export interface ArcadeImportBatchPreview<TPack extends NormalizedArcadePack = NormalizedArcadePack> {
packs: ArcadeImportPreview<TPack>[];
count: number;
wasRepaired: boolean;
}
@ -69,6 +125,74 @@ export interface ArcadeSessionSettings {
oneAwayFeedback: boolean;
}
export interface CrosswordSessionSettings {
size: CrosswordSize;
instantCheck: boolean;
allowHints: boolean;
}
export interface CrosswordPlacedEntry extends NormalizedCrosswordEntry {
direction: "across" | "down";
row: number;
column: number;
number: number;
cellKeys: string[];
}
export interface CrosswordCell {
key: string;
row: number;
column: number;
answer: string;
number?: number;
entryIds: string[];
}
export interface CrosswordLayout {
size: CrosswordSize;
seed: string;
targetCount: number;
rows: number;
columns: number;
cells: CrosswordCell[];
entries: CrosswordPlacedEntry[];
omittedEntries: NormalizedCrosswordEntry[];
}
export type CrosswordAction =
| { type: "CHECK_WORD"; entryId: string; value: string; elapsedMs: number }
| { type: "CHECK_PUZZLE"; answers: Record<string, string>; elapsedMs: number }
| { type: "ALTERNATE_CLUE"; entryId: string; elapsedMs: number }
| { type: "REVEAL_LETTER"; cellKey: string; elapsedMs: number }
| { type: "REVEAL_WORD"; entryId: string; elapsedMs: number };
export interface CrosswordEntryResult {
entryId: string;
clue: string;
answer: string;
playerAnswer: string;
explanation: string;
correct: boolean;
omitted: boolean;
revealedLetters: number;
revealedWord: boolean;
alternateClueUsed: boolean;
}
export interface CrosswordRoundResult {
outcome: "COMPLETED" | "GAVE_UP";
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
hintsUsed: number;
size: CrosswordSize;
placedCount: number;
omittedCount: number;
entries: CrosswordEntryResult[];
}
export interface ConnectionsSubmission {
itemIds: string[];
elapsedMs: number;
@ -106,7 +230,7 @@ export interface ArcadeAttemptSummary {
durationSeconds: number;
mistakes: number;
completedAt: string;
results?: ArcadeRoundResult;
results?: ArcadeRoundResult | CrosswordRoundResult;
}
export interface ArcadePackSummary {
@ -118,6 +242,8 @@ export interface ArcadePackSummary {
schemaVersion: number;
itemCount: number;
defaultAllowedMistakes: number;
defaultInstantCheck: boolean;
defaultAllowHints: boolean;
bestScore: number | null;
latestAttempt: ArcadeAttemptSummary | null;
}