Remove obsolete project artifacts
Some checks failed
Verify and publish container / build-and-push (push) Failing after 33s

This commit is contained in:
Elijah 2026-08-08 18:50:16 -07:00
parent 5db7fc8afd
commit e8dffc9692
58 changed files with 174 additions and 7563 deletions

View file

@ -56,11 +56,7 @@ function seedRealApplicationSchema() {
for (let index = 0; index < ACTIVITY_ROWS; index += 1) {
const ageDays = index % 730;
const type = index % 3 === 0
? "FLASHCARD"
: index % 3 === 1
? "QUIZ_QUESTION"
: "ARCADE_GROUP";
const type = index % 2 === 0 ? "FLASHCARD" : "QUIZ_QUESTION";
insertActivity.run(
`activity-${index}`,
type,

View file

@ -0,0 +1,10 @@
-- Remove Arcade-generated activity and saved instruction settings.
DELETE FROM "StudyActivity"
WHERE "type" IN ('ARCADE_GROUP', 'ARCADE_WORD');
DELETE FROM "Setting"
WHERE "key" IN ('llmInstructionsConnections', 'llmInstructionsCrossword');
-- Drop the dependent table before its parent while SQLite foreign keys are enabled.
DROP TABLE IF EXISTS "ArcadeAttempt";
DROP TABLE IF EXISTS "ArcadePack";

View file

@ -18,7 +18,6 @@ model Class {
quizSets QuizSet[]
materialGroups MaterialGroup[]
spacedRepetitionSets SpacedRepetitionSet[]
arcadePacks ArcadePack[]
}
model Deck {
@ -150,52 +149,12 @@ model Setting {
model StudyActivity {
id String @id @default(uuid())
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
type String // "FLASHCARD" | "QUIZ_QUESTION"
occurredAt DateTime @default(now())
@@index([occurredAt])
}
model ArcadePack {
id String @id @default(uuid())
classId String
gameType String
name String
description String?
schemaVersion Int
sourceJson String
normalizedJson String
validationReportJson String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
attempts ArcadeAttempt[]
@@index([classId, gameType, sortOrder])
}
model ArcadeAttempt {
id String @id @default(uuid())
arcadePackId String
mode String
score Int
maxScore Int
accuracy Float
durationSeconds Int
mistakes Int
hintsUsed Int
settingsJson String
resultsJson String
seed String
completedAt DateTime @default(now())
arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
@@index([arcadePackId, completedAt])
}
model MaterialGroup {
id String @id @default(uuid())
classId String

View file

@ -1,19 +0,0 @@
import { notFound } from "next/navigation";
import { randomUUID } from "node:crypto";
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
import { getArcadePack } from "@/services/arcadeService";
export default async function ConnectionsPlayPage(
props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
) {
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" || pack.normalized.type !== "connections") notFound();
const normalized = pack.normalized;
const mistakesValue = Number(query.mistakes);
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
? mistakesValue
: normalized.settings.allowedMistakes;
const Renderer = ARCADE_RENDERERS.connections;
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
}

View file

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

View file

@ -1,21 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,3 +0,0 @@
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
return <div className="arcade-route">{children}</div>;
}

View file

@ -1,46 +0,0 @@
import Link from "next/link";
import { ARCADE_GAMES } from "@/config/arcadeGames";
function ConnectionsVisual() {
return <div className="arcade-visual connections-visual" aria-hidden><div className="connections-visual-grid">{Array.from({ length: 16 }, (_, index) => <i key={index} />)}</div><span className="connections-visual-label">4 hidden groups</span></div>;
}
function CrosswordVisual() {
const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""];
return <div className="arcade-visual crossword-visual" aria-hidden><div className="crossword-grid">{cells.map((letter, index) => <i key={index} className={letter ? "has-letter" : ""}>{letter}</i>)}</div><span className="crossword-pencil"></span></div>;
}
function FallingBlocksVisual() {
return <div className="arcade-visual blocks-visual" aria-hidden><span className="blocks-arrow"></span><div className="blocks-piece blocks-piece-a"><i /><i /><i /><i /></div><div className="blocks-piece blocks-piece-b"><i /><i /><i /></div><div className="blocks-floor">{Array.from({ length: 9 }, (_, index) => <i key={index} />)}</div></div>;
}
function AsteroidVisual() {
return <div className="arcade-visual asteroid-visual" aria-hidden><i className="arcade-star star-a" /><i className="arcade-star star-b" /><i className="arcade-star star-c" /><span className="asteroid asteroid-a" /><span className="asteroid asteroid-b" /><span className="asteroid asteroid-c" /><span className="space-station"><i /></span><span className="laser-beam" /><span className="shield-ring" /></div>;
}
function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) {
if (gameKey === "connections") return <ConnectionsVisual />;
if (gameKey === "crossword") return <CrosswordVisual />;
if (gameKey === "falling-blocks") return <FallingBlocksVisual />;
return <AsteroidVisual />;
}
export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) {
const { classSlug } = await props.params;
return (
<div className="arcade-lobby pb-20">
<div className="arcade-lobby-heading mb-8">
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary">Insert curiosity</p>
<h2 className="mt-2 text-4xl font-black tracking-[-0.05em] text-text-heading sm:text-5xl">Choose your cabinet</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-text-secondary">Step away from the desk and turn your study material into a quick challenge.</p>
</div>
<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 ${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>
</div>
);
}

View file

@ -1,19 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function POST(request: NextRequest) {
const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
}
try {
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 });
}
throw error;
}
}

View file

@ -1,42 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
return NextResponse.json(await arcadeService.listArcadeAttempts(id));
}
export async function POST(
request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
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 = 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 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Attempt could not be saved" },
{ status: 400 }
);
}
}

View file

@ -1,23 +0,0 @@
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,38 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
return pack
? NextResponse.json(pack)
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}
export async function PATCH(
request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
return pack
? NextResponse.json(pack)
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}
export async function DELETE(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const deleted = await arcadeService.deleteArcadePack(id);
return deleted
? NextResponse.json({ success: true })
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}

View file

@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
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" && gameType !== "crossword")) {
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
}
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
}
export async function POST(request: NextRequest) {
const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
}
try {
const packs = await arcadeService.createArcadePacks(parsed.data);
return NextResponse.json({ packs, count: packs.length }, { status: 201 });
} catch (error) {
if (error instanceof ArcadeImportError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
}
if (error instanceof Error) {
const status = error.message === "Class not found" ? 404 : 400;
return NextResponse.json({ error: error.message }, { status });
}
throw error;
}
}

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 === "crossword" ? type : null;
return type === "flashcards" || type === "quizzes" ? type : null;
}
export async function GET(request: NextRequest) {

View file

@ -231,537 +231,6 @@
color: transparent;
}
/* Arcade routes take over the complete application shell, including navigation. */
body:has(.arcade-route) {
--theme-bg-base: #070b18;
--theme-bg-surface: #11182a;
--theme-bg-surface-alt: #19233a;
--theme-bg-callout: #282357;
--theme-primary: #9a8cff;
--theme-primary-hover: #b5aaff;
--theme-text-heading: #f9f7ff;
--theme-text-body: #dbe2f4;
--theme-text-secondary: #aab6d1;
--theme-text-muted: #71809e;
--theme-border: #34415c;
--theme-border-light: #242f46;
min-height: 100vh;
background:
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
}
body:has(.arcade-route)::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: .11;
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
background-size: 22px 22px;
}
body:has(.arcade-route) main { min-height: 100vh; }
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
body:has(.arcade-route) .app-sidebar,
body:has(.arcade-route) .app-mobile-header {
border-color: rgba(138, 157, 201, .15);
background: rgba(8, 13, 27, .9);
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
}
body:has(.connections-world) {
background:
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
}
.arcade-lobby-heading { position: relative; }
.arcade-lobby-heading::after {
content: "SELECT GAME";
position: absolute;
right: 0;
top: .5rem;
color: rgba(154, 140, 255, .08);
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 950;
letter-spacing: -.06em;
line-height: .8;
pointer-events: none;
}
.arcade-cabinet {
position: relative;
display: flex;
min-height: 30rem;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(148, 163, 202, .2);
border-radius: 1.75rem;
background: #11182a;
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
}
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
.arcade-cabinet.is-coming { filter: saturate(.82); }
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
.blocks-piece { position: absolute; display: grid; gap: 3px; }
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
/* Connections has its own playful game-show visual system, independent of the desk UI. */
.connections-world {
--theme-bg-surface: #182443;
--theme-bg-surface-alt: #223154;
--theme-bg-callout: #2c3d68;
--theme-primary: #ffd166;
--theme-primary-hover: #ffe29a;
--theme-text-heading: #f8fbff;
--theme-text-body: #dbe7ff;
--theme-text-secondary: #afc0df;
--theme-text-muted: #8497bd;
--theme-border: #52668d;
--theme-border-light: #33486f;
position: relative;
isolation: isolate;
overflow: hidden;
color: var(--theme-text-body);
background:
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
}
.connections-hub.connections-world,
.connections-stage.connections-world {
overflow: visible;
background: transparent;
box-shadow: none;
}
.connections-hub.connections-world::before,
.connections-stage.connections-world::before { display: none; }
.connections-world::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
opacity: .13;
background-image:
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
.connections-world .editorial-title {
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
font-weight: 850;
letter-spacing: -.045em;
}
.connections-hub-hero,
.connections-topbar,
.connections-status,
.connections-setup,
.connections-import {
backdrop-filter: blur(18px);
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
}
.connections-hub-hero {
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
border: 1px solid rgba(151, 174, 221, .18);
}
.connections-pack-card {
border-color: rgba(117, 141, 187, .28);
background: rgba(24, 36, 67, .74);
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
}
.connections-pack-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 209, 102, .48);
}
.connections-pack-card.is-active {
border-color: #ffd166;
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
}
.connections-primary-button {
color: #172033;
background: linear-gradient(135deg, #ffd166, #ffb84d);
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
}
.connections-primary-button:not(:disabled):hover {
transform: translateY(-2px) scale(1.01);
filter: brightness(1.06);
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
}
.connections-secondary-button {
color: #dbe7ff;
border: 1px solid #52668d;
background: rgba(24, 36, 67, .82);
transition: transform .18s var(--ease-spring), background .18s ease;
}
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
.connections-tile {
color: #15213a;
border: 1px solid rgba(255,255,255,.75);
background: linear-gradient(145deg, #fffaf0, #e9effa);
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
animation: connectionsTileIn .36s var(--ease-spring) both;
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
}
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
.connections-tile.is-selected {
color: #fff;
border-color: #8c7cf7;
background: linear-gradient(145deg, #735df2, #5540ca);
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
transform: translateY(2px) scale(.975);
animation: connectionsTileSelect .25s var(--ease-spring);
}
.connections-group,
.connections-review-card {
color: #172033;
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
}
.connections-group .text-text-heading,
.connections-group .text-text-secondary,
.connections-review-card .text-text-heading,
.connections-review-card .text-text-secondary { color: #172033; }
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
.connections-results-hero {
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
}
.connections-completion {
position: relative;
display: grid;
min-height: 32rem;
place-content: center;
overflow: hidden;
border-radius: 1.75rem;
text-align: center;
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
animation: connectionsCompletionIn .55s var(--ease-spring) both;
}
.connections-completion-mark {
display: grid;
width: 6rem;
height: 6rem;
margin: 0 auto 1.5rem;
place-items: center;
border-radius: 999px;
color: #172033;
background: #ffd166;
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
font-size: 3rem;
font-weight: 900;
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
}
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
.connections-confetti i {
position: absolute;
top: -8%;
left: var(--confetti-x);
width: 9px;
height: 17px;
border-radius: 2px;
background: var(--confetti-color);
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
}
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.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-assisted { color: #8a631c; background: #f2d47e; }
.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-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
.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-assisted { border-left: 6px solid #b58627; }
.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

@ -6,7 +6,6 @@ interface DailyActivity {
date: string;
flashcards: number;
questions: number;
arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@ -154,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
return (
<div role="tooltip" className="rounded-lg border border-border bg-bg-surface px-3 py-2 text-right text-xs shadow-xl dark:border-white/15 dark:bg-[#0d1422]">
<div className="font-bold text-text-heading dark:text-white">{formatted} · {day.total} total</div>
<div className="mt-0.5 text-text-secondary dark:text-white/60">{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups</div>
<div className="mt-0.5 text-text-secondary dark:text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
</div>
);
}
@ -185,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
}
function getDayAriaLabel(day: DailyActivity) {
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`;
}

View file

@ -1,38 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
export function ArcadeGameShell({
title,
exitHref,
elapsedSeconds,
complete,
worldClassName = "connections-world connections-stage",
children,
}: {
title: string;
exitHref: string;
elapsedSeconds: number;
complete: boolean;
worldClassName?: string;
children: ReactNode;
}) {
const router = useRouter();
function exitGame() {
if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
router.push(exitHref);
}
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
return (
<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>
</header>
{children}
</div>
);
}

View file

@ -1,127 +0,0 @@
"use client";
import { useState } from "react";
import { GenerateTab } from "@/components/import/GenerateTab";
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
export function ArcadeImportModal({
classId,
gameType,
onClose,
onImported,
}: {
classId: string;
gameType: ArcadeGameKey;
onClose: () => void;
onImported: () => void;
}) {
const [tab, setTab] = useState<"generate" | "import">("import");
const [rawJson, setRawJson] = useState("");
const [names, setNames] = useState<string[]>([]);
const [preview, setPreview] = useState<ArcadeImportBatchPreview | null>(null);
const [error, setError] = useState("");
const [details, setDetails] = useState<string[]>([]);
const [acknowledged, setAcknowledged] = useState(false);
const [busy, setBusy] = useState(false);
async function previewImport() {
setBusy(true);
setError("");
setDetails([]);
setPreview(null);
try {
const response = await fetch("/api/arcade/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameType, rawJson }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? "Import preview failed");
setDetails(data.details ?? []);
return;
}
setPreview(data);
setNames(data.packs.map((pack: { name: string }) => pack.name));
} finally {
setBusy(false);
}
}
async function saveImport() {
if (!preview || names.some((name) => !name.trim())) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/arcade/packs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
gameType,
rawJson,
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
warningsAcknowledged: acknowledged,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? "Import failed");
setDetails(data.details ?? []);
return;
}
onImported();
} finally {
setBusy(false);
}
}
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={`${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 {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">
{(["generate", "import"] as const).map((item) => (
<button key={item} onClick={() => setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}</button>
))}
</div>
<div className="overflow-y-auto p-6">
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
<div className="space-y-4">
<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 && (
<div className="space-y-4 rounded-2xl border border-primary/15 bg-bg-callout p-4">
<div className="flex items-center justify-between"><p className="font-extrabold text-text-heading">{preview.count} {preview.count === 1 ? "pack" : "packs"} ready</p>{preview.wasRepaired && <span className="text-xs font-bold text-primary">JSON syntax repaired</span>}</div>
<div className="space-y-3">
{preview.packs.map((pack, index) => (
<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" />
{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>
{preview.packs.some((pack) => pack.warnings.length > 0) && <div className="rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-text-secondary"><p className="font-bold text-text-heading">Review warnings</p><ul className="mt-1 list-disc space-y-1 pl-5">{preview.packs.flatMap((pack, index) => pack.warnings.map((warning) => `Pack ${index + 1}: ${warning}`)).map((warning) => <li key={warning}>{warning}</li>)}</ul><label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} className="mt-1" /><span>I reviewed these warnings and want to import the packs.</span></label></div>}
<button onClick={saveImport} disabled={busy || names.some((name) => !name.trim()) || (preview.packs.some((pack) => pack.warnings.length > 0) && !acknowledged)} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Importing…" : `Import ${preview.count} ${preview.count === 1 ? "pack" : "packs"}`}</button>
</div>
)}
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -1,237 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsPack,
} from "@/types/arcade";
export function ConnectionsGame({
seed,
packId,
packName,
classSlug,
pack,
settings,
}: {
seed: string;
packId: string;
packName: string;
classSlug: string;
pack: NormalizedConnectionsPack;
settings: ArcadeSessionSettings;
}) {
const allItems = useMemo(() => pack.content.flatMap((group) => group.items), [pack]);
const itemById = useMemo(() => new Map(allItems.map((item) => [item.id, item])), [allItems]);
const [tileOrder, setTileOrder] = useState(() => deterministicShuffle(allItems.map((item) => item.id), seed));
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [submissions, setSubmissions] = useState<ConnectionsSubmission[]>([]);
const [feedback, setFeedback] = useState("Select four related tiles.");
const [result, setResult] = useState<ArcadeRoundResult | null>(null);
const [finishing, setFinishing] = useState<ArcadeRoundResult | null>(null);
const [feedbackKind, setFeedbackKind] = useState<"idle" | "correct" | "wrong">("idle");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const shuffleCount = useRef(0);
const startedAt = useRef(0);
const replay = useMemo(
() => replayConnectionsAttempt(pack, submissions, settings, elapsedSeconds),
[elapsedSeconds, pack, settings, submissions]
);
const solvedGroupIds = useMemo(
() => new Set(replay.result.groups.filter((group) => group.solved).map((group) => group.groupId)),
[replay.result.groups]
);
const solvedItemIds = useMemo(
() => new Set(pack.content.filter((group) => solvedGroupIds.has(group.id)).flatMap((group) => group.items.map((item) => item.id))),
[pack, solvedGroupIds]
);
const unresolvedOrder = tileOrder.filter((id) => !solvedItemIds.has(id));
useEffect(() => {
if (startedAt.current === 0) startedAt.current = Date.now();
if (result) return;
const interval = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
return () => window.clearInterval(interval);
}, [result]);
function toggleTile(id: string) {
if (result || solvedItemIds.has(id)) return;
setSelectedIds((current) => current.includes(id) ? current.filter((itemId) => itemId !== id) : current.length < 4 ? [...current, id] : current);
}
async function persistAttempt(nextSubmissions: ConnectionsSubmission[], roundResult: ArcadeRoundResult) {
setSaveState("saving");
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "CLASSIC",
durationSeconds: roundResult.durationSeconds,
seed,
settings,
submissions: nextSubmissions,
}),
});
if (!response.ok) {
setSaveState("error");
return;
}
await response.json();
setSaveState("saved");
}
function submitSelection() {
if (selectedIds.length !== 4 || result) return;
const previousSolved = solvedGroupIds.size;
const nextSubmissions = [...submissions, { itemIds: selectedIds, elapsedMs: Date.now() - startedAt.current }];
const duration = Math.floor((Date.now() - startedAt.current) / 1000);
const nextReplay = replayConnectionsAttempt(pack, nextSubmissions, settings, duration);
setSubmissions(nextSubmissions);
setSelectedIds([]);
if (nextReplay.result.groups.filter((group) => group.solved).length > previousSolved) {
setFeedback("Correct group found.");
setFeedbackKind("correct");
} else if (nextReplay.result.hintsUsed > replay.result.hintsUsed) {
setFeedback("One away — three of those tiles belong together.");
setFeedbackKind("wrong");
} else {
setFeedback("Not a group. Try another combination.");
setFeedbackKind("wrong");
}
if (nextReplay.complete) {
setElapsedSeconds(duration);
setFinishing(nextReplay.result);
window.setTimeout(() => {
setResult(nextReplay.result);
setFinishing(null);
}, nextReplay.result.outcome === "WON" ? 1700 : 850);
void persistAttempt(nextSubmissions, nextReplay.result);
}
}
function shuffleTiles() {
shuffleCount.current += 1;
const shuffled = deterministicShuffle(unresolvedOrder, `${seed}-shuffle-${shuffleCount.current}`);
setTileOrder([...tileOrder.filter((id) => solvedItemIds.has(id)), ...shuffled]);
setSelectedIds([]);
setFeedback("Unsolved tiles shuffled.");
setFeedbackKind("idle");
}
function handleTileKey(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 };
const move = moves[event.key];
if (!move) return;
event.preventDefault();
const index = unresolvedOrder.indexOf(id);
const next = unresolvedOrder[(index + move + unresolvedOrder.length) % unresolvedOrder.length];
document.querySelector<HTMLButtonElement>(`[data-arcade-tile="${next}"]`)?.focus();
}
if (result) {
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
<ConnectionsResults result={result} itemById={itemById} saveState={saveState} onReplay={() => window.location.reload()} exitHref={`/${classSlug}/arcade/connections`} />
</ArcadeGameShell>
);
}
if (finishing) {
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
<CompletionTransition result={finishing} />
</ArcadeGameShell>
);
}
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete={false}>
<div className="connections-status mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border-light bg-bg-surface p-4">
<div><p className="text-xs font-bold uppercase tracking-wide text-text-muted">Mistakes remaining</p><div className="mt-1 flex gap-1.5" aria-label={`${settings.allowedMistakes - replay.result.mistakes} mistakes remaining`}>{Array.from({ length: settings.allowedMistakes }, (_, index) => <span key={index} className={`h-3 w-3 rounded-full border ${index < settings.allowedMistakes - replay.result.mistakes ? "border-primary bg-primary" : "border-border bg-bg-surface-alt"}`} />)}</div></div>
<p className="text-sm font-bold text-text-secondary" aria-live="polite">{feedback}</p>
</div>
{pack.content.filter((group) => solvedGroupIds.has(group.id)).map((group, index) => (
<div key={group.id} className={`connections-group connections-group-${index % 4} mb-2 rounded-2xl p-4 text-center`}>
<h2 className="font-extrabold uppercase tracking-wide text-text-heading">{group.category}</h2>
<p className="mt-1 text-sm font-semibold text-text-secondary">{group.items.map((item) => item.text).join(" · ")}</p>
</div>
))}
<div key={`board-${submissions.length}`} className={`connections-board grid grid-cols-4 gap-2 sm:gap-3 ${feedbackKind === "wrong" ? "animate-connections-shake" : ""}`} role="group" aria-label="Connections tiles">
{unresolvedOrder.map((id) => {
const item = itemById.get(id);
if (!item) return null;
const selected = selectedIds.includes(id);
return <button key={id} data-arcade-tile={id} aria-pressed={selected} onKeyDown={(event) => handleTileKey(event, id)} onClick={() => toggleTile(id)} className={`connections-tile min-h-20 break-words rounded-xl px-1.5 py-2 text-[11px] font-extrabold leading-tight sm:min-h-24 sm:px-3 sm:text-sm ${selected ? "is-selected" : ""}`}>{item.text}</button>;
})}
</div>
<div className="mt-5 grid grid-cols-2 gap-3 sm:flex sm:justify-center">
<button onClick={shuffleTiles} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold">Shuffle</button>
<button onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold disabled:opacity-40">Clear</button>
<button onClick={submitSelection} disabled={selectedIds.length !== 4} className="connections-primary-button col-span-2 min-h-11 rounded-xl px-7 text-sm font-extrabold disabled:opacity-40 sm:col-span-1">Submit group ({selectedIds.length}/4)</button>
</div>
</ArcadeGameShell>
);
}
function ConnectionsResults({
result,
itemById,
saveState,
onReplay,
exitHref,
}: {
result: ArcadeRoundResult;
itemById: Map<string, { id: string; text: string }>;
saveState: "idle" | "saving" | "saved" | "error";
onReplay: () => void;
exitHref: string;
}) {
const orderedGroups = [...result.groups].sort((a, b) => Number(a.solved) - Number(b.solved));
return (
<div className="animate-connections-results-in">
<section className="connections-results-hero rounded-3xl p-6 text-white sm:p-8">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/55">Round complete</p>
<h2 className="editorial-title mt-1 text-4xl text-white">{result.outcome === "WON" ? "Board cleared" : "Groups revealed"}</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)}%`], ["Mistakes", result.mistakes], ["Time", `${result.durationSeconds}s`]].map(([label, value]) => <div key={label} className="rounded-2xl bg-white/8 p-3"><p className="text-xs font-bold uppercase text-white/45">{label}</p><p className="mt-1 text-xl font-extrabold">{value}</p></div>)}</div>
<p className="mt-4 text-xs text-white/55">{saveState === "saving" ? "Saving attempt…" : saveState === "error" ? "Attempt could not be saved." : "Attempt saved."}</p>
</section>
<div className="mt-5 space-y-3">{orderedGroups.map((group, index) => <article key={group.groupId} className={`connections-review-card connections-group-${index % 4} rounded-2xl p-4`}><p className="text-xs font-extrabold uppercase tracking-wide opacity-60">{group.solved ? "Solved" : "Revealed"}</p><h3 className="mt-1 text-lg font-extrabold">{group.category}</h3><p className="mt-1 text-sm font-semibold opacity-75">{group.items.join(" · ")}</p><p className="mt-3 text-sm leading-6 opacity-80">{group.explanation}</p></article>)}</div>
{result.incorrectSelections.length > 0 && <section className="mt-5 rounded-2xl border border-border-light bg-bg-surface p-4"><h3 className="font-extrabold text-text-heading">Incorrect selections</h3><ul className="mt-2 space-y-1 text-sm text-text-secondary">{result.incorrectSelections.map((selection, index) => <li key={`${selection.join("-")}-${index}`}>{selection.map((id) => itemById.get(id)?.text ?? id).join(" · ")}</li>)}</ul></section>}
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={onReplay} className="min-h-12 rounded-xl bg-primary px-5 text-sm font-extrabold text-white">Play again</button><a href={exitHref} className="flex min-h-12 items-center justify-center rounded-xl border border-border bg-bg-surface px-5 text-sm font-extrabold text-text-heading">Back to packs</a></div>
</div>
);
}
function CompletionTransition({ result }: { result: ArcadeRoundResult }) {
const won = result.outcome === "WON";
return (
<div className={`connections-completion ${won ? "is-win" : "is-loss"}`}>
{won && <ConfettiBurst />}
<div className="connections-completion-mark" aria-hidden>{won ? "✓" : "!"}</div>
<p>{won ? "Perfect connection" : "Round complete"}</p>
<h2>{won ? "Board cleared!" : "Groups revealed"}</h2>
<span>{won ? `${result.score} points` : "Lets review the board"}</span>
</div>
);
}
function ConfettiBurst() {
const colors = ["#ffd166", "#ef476f", "#06d6a0", "#4cc9f0", "#a78bfa"];
return <div className="connections-confetti" aria-hidden>{Array.from({ length: 52 }, (_, index) => {
const style = {
"--confetti-x": `${(index * 37) % 100}%`,
"--confetti-delay": `${(index % 13) * 0.035}s`,
"--confetti-spin": `${180 + (index % 7) * 80}deg`,
"--confetti-color": colors[index % colors.length],
} as CSSProperties;
return <i key={index} style={style} />;
})}</div>;
}

View file

@ -1,123 +0,0 @@
"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 type { ArcadeAttemptSummary, ArcadePackSummary } from "@/types/arcade";
export function ConnectionsHub({
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 [allowedMistakes, setAllowedMistakes] = useState(initialPacks[0]?.defaultAllowedMistakes ?? 4);
const [oneAwayFeedback, setOneAwayFeedback] = useState(true);
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
const [attemptsLoading, setAttemptsLoading] = 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]);
function selectPack(pack: ArcadePackSummary) {
setSelectedId(pack.id);
setAllowedMistakes(pack.defaultAllowedMistakes);
setAttempts([]);
setAttemptsLoading(true);
}
async function renamePack(pack: ArcadePackSummary) {
const name = window.prompt("Rename Connections 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/connections/${selected.id}/play?mistakes=${allowedMistakes}&oneAway=${oneAwayFeedback ? "1" : "0"}`
: "#";
return (
<div className="connections-world connections-hub p-1 pb-20 sm:p-3">
<div className="connections-hub-hero mb-7 flex flex-col gap-4 rounded-3xl p-5 sm:flex-row 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="text-xs font-bold uppercase tracking-[0.18em] text-primary">The pattern room</p>
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Connections</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a pack, tune the mistake limit, and find the four hidden groups.</p>
</div>
<button onClick={() => setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack</button>
</div>
{initialPacks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
<div className="mx-auto mb-4 grid w-fit grid-cols-2 gap-1.5" aria-hidden>{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => <span key={color} className={`h-8 w-8 rounded-lg ${color}`} />)}</div>
<h3 className="editorial-title text-2xl text-text-heading">Import your first board</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Connections uses purpose-built packs with four groups of four terms.</p>
<button onClick={() => setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON</button>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,.85fr)]">
<section>
<h3 className="mb-3 text-sm font-extrabold uppercase tracking-[0.14em] text-text-muted">Study packs</h3>
<div className="space-y-3">
{initialPacks.map((pack) => {
const active = pack.id === effectiveSelectedId;
return (
<article key={pack.id} className={`connections-pack-card rounded-2xl border p-4 transition-all ${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-lg 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 shrink-0 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 className="rounded-full bg-bg-surface-alt px-2.5 py-1">16 tiles</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Best {pack.bestScore ?? "—"}/400</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">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 hover:text-text-heading">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="connections-setup h-fit rounded-3xl border border-border-light bg-bg-surface p-5 lg:sticky lg:top-6">
<h3 className="editorial-title text-2xl text-text-heading">Round setup</h3>
{selected ? <>
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
<label className="mt-5 block text-sm font-bold text-text-heading">Allowed mistakes <span className="text-primary">{allowedMistakes}</span></label>
<input type="range" min={1} max={8} value={allowedMistakes} onChange={(event) => setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
<label className="mt-5 flex items-start gap-3 rounded-xl bg-bg-surface-alt p-3 text-sm text-text-secondary"><input type="checkbox" checked={oneAwayFeedback} onChange={(event) => setOneAwayFeedback(event.target.checked)} className="mt-1" /><span><strong className="block text-text-heading">One-away feedback</strong>Tell me when three selected tiles belong together.</span></label>
<div className="mt-5 rounded-xl border border-border-light p-3 text-sm text-text-secondary"><div className="flex justify-between"><span>Board</span><strong className="text-text-heading">4 × 4</strong></div><div className="mt-2 flex justify-between"><span>Possible score</span><strong className="text-text-heading">400</strong></div></div>
<Link href={playHref} className="connections-primary-button mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Enter the board</Link>
<div className="mt-7 border-t border-border-light pt-5"><h4 className="text-sm font-extrabold uppercase tracking-wide text-text-muted">Recent attempts</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"><span className="font-bold text-text-heading">{attempt.score}/400</span><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 the round.</p>}
</aside>
</div>
)}
{showImport && <ArcadeImportModal classId={classId} gameType="connections" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
</div>
);
}

View file

@ -1,269 +0,0 @@
"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} revealedCells={revealedCells} 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, revealedCells, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<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} revealedCells={revealedCells} />
<div className="mt-5 space-y-3">{ordered.map((entry) => {
const assisted = entry.revealedWord || entry.revealedLetters > 0;
return <article key={entry.entryId} className={`crossword-review rounded-2xl p-4 ${assisted ? "is-assisted" : entry.correct ? "is-correct" : "is-incorrect"}`}><p className="text-xs font-extrabold uppercase tracking-wide">{assisted ? "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, revealedCells }: { layout: CrosswordLayout; answers: Record<string, string>; revealedCells: Set<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-assisted" />Revealed or assisted</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 assisted = revealedCells.has(cell.key);
const correct = answers[cell.key] === cell.answer;
const state = assisted ? "assisted" : correct ? "correct" : "incorrect";
return <div key={cell.key} className={`crossword-final-cell is-${state}`} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} aria-label={`${cell.answer}, ${state === "incorrect" ? "incorrect or blank" : state}`}><small>{cell.number}</small><strong>{cell.answer}</strong></div>;
})}
</div>
</div>
</section>;
}

View file

@ -1,152 +0,0 @@
"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) {
if (pack.id === effectiveSelectedId) return;
setSelectedId(pack.id);
setInstantCheck(pack.defaultInstantCheck);
setAllowHints(pack.defaultAllowHints);
setAttempts([]);
setAttemptsLoading(true);
setPreviewLayout(null);
setPreviewLoading(true);
}
function selectSize(nextSize: CrosswordSize) {
if (nextSize === size) return;
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,7 +0,0 @@
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,20 +2,14 @@
import { useState, useEffect, useCallback } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
const [copied, setCopied] = useState(false);
const [packCount, setPackCount] = useState(1);
const [loadError, setLoadError] = useState<string | null>(null);
const batchOverride = importType === "connections" && packCount > 1
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
: "";
const displayedInstructions = `${batchOverride}${instructions}`;
const loadInstructions = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setLoadError(null);
@ -84,7 +78,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
async function handleCopy() {
try {
await navigator.clipboard.writeText(displayedInstructions);
await navigator.clipboard.writeText(instructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
@ -112,23 +106,9 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
</p>
{importType === "connections" && (
<div className="rounded-xl border border-border bg-bg-surface-alt/60 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<label htmlFor="connections-pack-count" className="text-sm font-bold text-text-heading">Number of game packs</label>
<p className="text-xs text-text-muted">This temporarily updates the copied prompt. Your saved instructions stay unchanged.</p>
</div>
<output htmlFor="connections-pack-count" className="grid h-11 min-w-12 place-items-center rounded-lg border border-border bg-bg-surface px-3 text-lg font-extrabold text-text-heading">{packCount}</output>
</div>
<input id="connections-pack-count" type="range" min={1} max={10} step={1} value={packCount} onChange={(event) => setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
<div className="mt-1 flex justify-between text-[10px] font-bold text-text-muted" aria-hidden><span>1</span><span>5</span><span>10</span></div>
</div>
)}
<textarea
value={displayedInstructions}
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
rows={14}
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>

View file

@ -1,34 +0,0 @@
export const ARCADE_GAMES = [
{
key: "connections",
name: "Connections",
path: "connections",
available: true,
estimatedMinutes: "510 min",
description: "Find four hidden relationships among sixteen terms.",
},
{
key: "crossword",
name: "Crossword",
path: "crossword",
available: true,
estimatedMinutes: "1045 min",
description: "Turn course terminology into an interlocking word puzzle.",
},
{
key: "falling-blocks",
name: "Falling Blocks",
path: "falling-blocks",
available: false,
estimatedMinutes: "Coming soon",
description: "Match prompts to answers before the board fills up.",
},
{
key: "asteroid-defense",
name: "Asteroid Defense",
path: "asteroid-defense",
available: false,
estimatedMinutes: "Coming soon",
description: "Defend the station by targeting the correct answers.",
},
] as const;

View file

@ -2,7 +2,6 @@ export const STUDY_MODES = [
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
{ key: "quizzes", label: "Quizzes", path: "quizzes", icon: "✓" },
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition", icon: "↻" },
{ key: "arcade", label: "Arcade", path: "arcade", icon: "◆" },
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
] as const;

View file

@ -77,16 +77,6 @@ export type Setting = Prisma.SettingModel
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*

View file

@ -101,16 +101,6 @@ export type Setting = Prisma.SettingModel
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*

File diff suppressed because one or more lines are too long

View file

@ -80,12 +80,12 @@ export type PrismaVersion = {
}
/**
* Prisma Client JS version: 7.9.1
* Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
* Prisma Client JS version: 7.8.0
* Query Engine version: 3c6e192761c0362d496ed980de936e2f3cebcd3a
*/
export const prismaVersion: PrismaVersion = {
client: "7.9.1",
engine: "e922089b7d7502aff4249d5da3420f6fa55fc6ad"
client: "7.8.0",
engine: "3c6e192761c0362d496ed980de936e2f3cebcd3a"
}
/**
@ -155,19 +155,6 @@ export type Subset<T, U> = {
[key in keyof T]: key extends keyof U ? T[key] : never;
};
/**
* Resolved type of the argument passed to the `PrismaClient` constructor.
*
* When called without a narrower options type (the common case), this resolves
* to `PrismaClientOptions` directly, which produces a clear TypeScript error
* message (`not assignable to parameter of type 'PrismaClientOptions'`) when
* the argument is missing or incomplete. When the user supplies a narrower
* options type (e.g. via a literal), it falls back to `Subset` to keep
* filtering out unknown properties.
*/
export type PrismaClientConstructorArgs<Options extends PrismaClientOptions> =
[PrismaClientOptions] extends [Options] ? PrismaClientOptions : Subset<Options, PrismaClientOptions>;
/**
* SelectSubset
* @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
@ -200,7 +187,7 @@ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> =
T extends object ?
U extends object ?
((Without<T, U> & U) | (Without<U, T> & T)) & object
(Without<T, U> & U) | (Without<U, T> & T)
: U : T
@ -409,8 +396,6 @@ export const ModelName = {
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
@ -430,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@ -1322,154 +1307,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ArcadePack: {
payload: Prisma.$ArcadePackPayload<ExtArgs>
fields: Prisma.ArcadePackFieldRefs
operations: {
findUnique: {
args: Prisma.ArcadePackFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ArcadePackFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
findFirst: {
args: Prisma.ArcadePackFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
}
findFirstOrThrow: {
args: Prisma.ArcadePackFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
findMany: {
args: Prisma.ArcadePackFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
create: {
args: Prisma.ArcadePackCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
createMany: {
args: Prisma.ArcadePackCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ArcadePackCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
delete: {
args: Prisma.ArcadePackDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
update: {
args: Prisma.ArcadePackUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
deleteMany: {
args: Prisma.ArcadePackDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ArcadePackUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ArcadePackUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
upsert: {
args: Prisma.ArcadePackUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
aggregate: {
args: Prisma.ArcadePackAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadePack>
}
groupBy: {
args: Prisma.ArcadePackGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadePackGroupByOutputType>[]
}
count: {
args: Prisma.ArcadePackCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadePackCountAggregateOutputType> | number
}
}
}
ArcadeAttempt: {
payload: Prisma.$ArcadeAttemptPayload<ExtArgs>
fields: Prisma.ArcadeAttemptFieldRefs
operations: {
findUnique: {
args: Prisma.ArcadeAttemptFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ArcadeAttemptFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
findFirst: {
args: Prisma.ArcadeAttemptFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
}
findFirstOrThrow: {
args: Prisma.ArcadeAttemptFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
findMany: {
args: Prisma.ArcadeAttemptFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
create: {
args: Prisma.ArcadeAttemptCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
createMany: {
args: Prisma.ArcadeAttemptCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ArcadeAttemptCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
delete: {
args: Prisma.ArcadeAttemptDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
update: {
args: Prisma.ArcadeAttemptUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
deleteMany: {
args: Prisma.ArcadeAttemptDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ArcadeAttemptUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ArcadeAttemptUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
upsert: {
args: Prisma.ArcadeAttemptUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
aggregate: {
args: Prisma.ArcadeAttemptAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadeAttempt>
}
groupBy: {
args: Prisma.ArcadeAttemptGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptGroupByOutputType>[]
}
count: {
args: Prisma.ArcadeAttemptCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptCountAggregateOutputType> | number
}
}
}
MaterialGroup: {
payload: Prisma.$MaterialGroupPayload<ExtArgs>
fields: Prisma.MaterialGroupFieldRefs
@ -1945,43 +1782,6 @@ export const StudyActivityScalarFieldEnum = {
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',
@ -2106,10 +1906,19 @@ export type BatchPayload = {
export const defineExtension = runtime.Extensions.defineExtension as unknown as runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs>
export type DefaultPrismaClient = PrismaClient
export type ErrorFormat = 'pretty' | 'colorless' | 'minimal'
/**
* Options common to all variants of `PrismaClientOptions`, regardless of whether you connect to your database through a driver adapter or through Prisma Accelerate.
*/
export interface PrismaClientBaseOptions {
export type PrismaClientOptions = ({
/**
* Instance of a Driver Adapter, e.g., like one provided by `@prisma/adapter-pg`.
*/
adapter: runtime.SqlDriverAdapterFactory
accelerateUrl?: never
} | {
/**
* Prisma Accelerate URL allowing the client to connect through Accelerate instead of a direct database.
*/
accelerateUrl: string
adapter?: never
}) & {
/**
* @default "colorless"
*/
@ -2196,56 +2005,6 @@ export interface PrismaClientBaseOptions {
*/
queryPlanCacheMaxSize?: number
}
/**
* `PrismaClient` options for connecting to your database through Prisma Accelerate instead of a driver adapter.
*
* Learn more: https://pris.ly/d/accelerate
*/
export interface PrismaClientOptionsWithAccelerateUrl extends PrismaClientBaseOptions {
/**
* The Prisma Accelerate connection URL. Use this option to connect to your database through Prisma Accelerate instead of using a driver adapter to connect directly.
*
* Learn more: https://pris.ly/d/accelerate
*/
accelerateUrl: string
adapter?: never
}
/**
* `PrismaClient` options for connecting to your database through a driver adapter. This is the common case in Prisma 7.
*
* Learn more: https://pris.ly/d/driver-adapters
*/
export interface PrismaClientOptionsWithAdapter extends PrismaClientBaseOptions {
/**
* A driver adapter that PrismaClient uses to connect to your database, such as the ones provided by `@prisma/adapter-pg`, `@prisma/adapter-libsql`, `@prisma/adapter-planetscale`, etc.
*
* A driver adapter is **required** unless you connect to your database through Prisma Accelerate (in which case use `accelerateUrl` instead).
*
* Learn more: https://pris.ly/d/driver-adapters
*
* @example
* ```ts
* import { PrismaPg } from '@prisma/adapter-pg'
* import { PrismaClient } from './generated/prisma/client'
*
* const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
* const prisma = new PrismaClient({ adapter })
* ```
*/
adapter: runtime.SqlDriverAdapterFactory
accelerateUrl?: never
}
/**
* Options passed to the `PrismaClient` constructor.
*
* A driver adapter (or, alternatively, a Prisma Accelerate URL) is **required**. See {@link PrismaClientOptionsWithAdapter} and {@link PrismaClientOptionsWithAccelerateUrl} for the two variants. All other properties live in {@link PrismaClientBaseOptions} and are optional.
*
* Learn more about driver adapters: https://pris.ly/d/driver-adapters
*/
export type PrismaClientOptions = PrismaClientOptionsWithAccelerateUrl | PrismaClientOptionsWithAdapter
export type GlobalOmitConfig = {
class?: Prisma.ClassOmit
deck?: Prisma.DeckOmit
@ -2259,8 +2018,6 @@ export type GlobalOmitConfig = {
authSecurity?: Prisma.AuthSecurityOmit
setting?: Prisma.SettingOmit
studyActivity?: Prisma.StudyActivityOmit
arcadePack?: Prisma.ArcadePackOmit
arcadeAttempt?: Prisma.ArcadeAttemptOmit
materialGroup?: Prisma.MaterialGroupOmit
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit

View file

@ -63,8 +63,6 @@ export const ModelName = {
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
@ -227,43 +225,6 @@ export const StudyActivityScalarFieldEnum = {
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',

View file

@ -20,8 +20,6 @@ export type * from './models/ShareLink'
export type * from './models/AuthSecurity'
export type * from './models/Setting'
export type * from './models/StudyActivity'
export type * from './models/ArcadePack'
export type * from './models/ArcadeAttempt'
export type * from './models/MaterialGroup'
export type * from './models/SpacedRepetitionSet'
export type * from './models/SpacedRepetitionSetDeck'

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -220,7 +220,6 @@ export type ClassWhereInput = {
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}
export type ClassOrderByWithRelationInput = {
@ -233,7 +232,6 @@ export type ClassOrderByWithRelationInput = {
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
}
export type ClassWhereUniqueInput = Prisma.AtLeast<{
@ -249,7 +247,6 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}, "id" | "slug">
export type ClassOrderByWithAggregationInput = {
@ -286,7 +283,6 @@ export type ClassCreateInput = {
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateInput = {
@ -299,7 +295,6 @@ export type ClassUncheckedCreateInput = {
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassUpdateInput = {
@ -312,7 +307,6 @@ export type ClassUpdateInput = {
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateInput = {
@ -325,7 +319,6 @@ export type ClassUncheckedUpdateInput = {
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateManyInput = {
@ -433,20 +426,6 @@ export type ClassUpdateOneRequiredWithoutQuizSetsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutQuizSetsInput, Prisma.ClassUpdateWithoutQuizSetsInput>, Prisma.ClassUncheckedUpdateWithoutQuizSetsInput>
}
export type ClassCreateNestedOneWithoutArcadePacksInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutArcadePacksNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
upsert?: Prisma.ClassUpsertWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutArcadePacksInput, Prisma.ClassUpdateWithoutArcadePacksInput>, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassCreateNestedOneWithoutMaterialGroupsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
@ -484,7 +463,6 @@ export type ClassCreateWithoutDecksInput = {
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutDecksInput = {
@ -496,7 +474,6 @@ export type ClassUncheckedCreateWithoutDecksInput = {
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutDecksInput = {
@ -524,7 +501,6 @@ export type ClassUpdateWithoutDecksInput = {
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutDecksInput = {
@ -536,7 +512,6 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutQuizSetsInput = {
@ -548,7 +523,6 @@ export type ClassCreateWithoutQuizSetsInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutQuizSetsInput = {
@ -560,7 +534,6 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutQuizSetsInput = {
@ -588,7 +561,6 @@ export type ClassUpdateWithoutQuizSetsInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
@ -600,71 +572,6 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutArcadePacksInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
}
export type ClassUpsertWithoutArcadePacksInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutArcadePacksInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutMaterialGroupsInput = {
@ -676,7 +583,6 @@ export type ClassCreateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
@ -688,7 +594,6 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
@ -716,7 +621,6 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
@ -728,7 +632,6 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
@ -740,7 +643,6 @@ export type ClassCreateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
@ -752,7 +654,6 @@ export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
@ -780,7 +681,6 @@ export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
@ -792,7 +692,6 @@ export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
@ -805,7 +704,6 @@ export type ClassCountOutputType = {
quizSets: number
materialGroups: number
spacedRepetitionSets: number
arcadePacks: number
}
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@ -813,7 +711,6 @@ export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
}
/**
@ -854,13 +751,6 @@ export type ClassCountOutputTypeCountSpacedRepetitionSetsArgs<ExtArgs extends ru
where?: Prisma.SpacedRepetitionSetWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountArcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ArcadePackWhereInput
}
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -872,7 +762,6 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["class"]>
@ -906,7 +795,6 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@ -919,7 +807,6 @@ export type $ClassPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
arcadePacks: Prisma.$ArcadePackPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -1325,7 +1212,6 @@ export interface Prisma__ClassClient<T, Null = never, ExtArgs extends runtime.Ty
quizSets<T extends Prisma.Class$quizSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$quizSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$QuizSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
materialGroups<T extends Prisma.Class$materialGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$materialGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MaterialGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionSets<T extends Prisma.Class$spacedRepetitionSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
arcadePacks<T extends Prisma.Class$arcadePacksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$arcadePacksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ArcadePackPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1846,30 +1732,6 @@ export type Class$spacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensi
distinct?: Prisma.SpacedRepetitionSetScalarFieldEnum | Prisma.SpacedRepetitionSetScalarFieldEnum[]
}
/**
* Class.arcadePacks
*/
export type Class$arcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ArcadePack
*/
select?: Prisma.ArcadePackSelect<ExtArgs> | null
/**
* Omit specific fields from the ArcadePack
*/
omit?: Prisma.ArcadePackOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ArcadePackInclude<ExtArgs> | null
where?: Prisma.ArcadePackWhereInput
orderBy?: Prisma.ArcadePackOrderByWithRelationInput | Prisma.ArcadePackOrderByWithRelationInput[]
cursor?: Prisma.ArcadePackWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ArcadePackScalarFieldEnum | Prisma.ArcadePackScalarFieldEnum[]
}
/**
* Class without action
*/

View file

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

View file

@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { NormalizedConnectionsPack } from "@/types/arcade";
const pack: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: "Test",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
id: `group-${groupIndex + 1}`,
category: `Category ${groupIndex + 1}`,
explanation: `Explanation ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => ({
id: `g${groupIndex + 1}-i${itemIndex + 1}`,
text: `Item ${groupIndex + 1}-${itemIndex + 1}`,
})),
})),
};
const settings = { allowedMistakes: 4, oneAwayFeedback: true };
describe("Connections engine", () => {
it("shuffles deterministically for a seed", () => {
const items = Array.from({ length: 16 }, (_, index) => index);
expect(deterministicShuffle(items, "round-1")).toEqual(deterministicShuffle(items, "round-1"));
expect(deterministicShuffle(items, "round-1")).not.toEqual(deterministicShuffle(items, "round-2"));
});
it("scores a perfect completed round", () => {
const submissions = pack.content.map((group, index) => ({
itemIds: group.items.map((item) => item.id),
elapsedMs: (index + 1) * 1000,
}));
const replay = replayConnectionsAttempt(pack, submissions, settings, 12);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "WON", score: 400, mistakes: 0, accuracy: 1 });
});
it("deducts mistakes and detects one-away selections", () => {
const wrong = [
...pack.content[0].items.slice(0, 3).map((item) => item.id),
pack.content[1].items[0].id,
];
const submissions = [
{ itemIds: wrong, elapsedMs: 1000 },
...pack.content.map((group, index) => ({ itemIds: group.items.map((item) => item.id), elapsedMs: (index + 2) * 1000 })),
];
const replay = replayConnectionsAttempt(pack, submissions, settings, 20);
expect(replay.result).toMatchObject({ outcome: "WON", score: 390, mistakes: 1, hintsUsed: 1, accuracy: 0.8 });
});
it("ends after the configured number of mistakes", () => {
const wrong = [pack.content[0].items[0].id, pack.content[1].items[0].id, pack.content[2].items[0].id, pack.content[3].items[0].id];
const replay = replayConnectionsAttempt(pack, Array.from({ length: 4 }, (_, index) => ({ itemIds: wrong, elapsedMs: index * 1000 })), settings, 10);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "LOST", score: 0, mistakes: 4 });
});
it("rejects selections containing unknown item ids", () => {
expect(() => replayConnectionsAttempt(pack, [{ itemIds: ["missing", "a", "b", "c"], elapsedMs: 0 }], settings, 0)).toThrow("invalid tile selection");
});
});

View file

@ -1,110 +0,0 @@
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsGroup,
NormalizedConnectionsPack,
} from "@/types/arcade";
function hashSeed(seed: string) {
let value = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
value ^= seed.charCodeAt(index);
value = Math.imul(value, 16777619);
}
return value >>> 0;
}
function seededRandom(seed: string) {
let state = hashSeed(seed);
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
export function deterministicShuffle<T>(items: readonly T[], seed: string): T[] {
const result = [...items];
const random = seededRandom(seed);
for (let index = result.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
}
return result;
}
function groupForSelection(groups: NormalizedConnectionsGroup[], itemIds: string[]) {
const selected = new Set(itemIds);
return groups.find(
(group) => group.items.length === selected.size && group.items.every((item) => selected.has(item.id))
);
}
export function isOneAway(
groups: NormalizedConnectionsGroup[],
selectedItemIds: string[],
solvedGroupIds: Set<string>
) {
const selected = new Set(selectedItemIds);
return groups.some(
(group) =>
!solvedGroupIds.has(group.id) &&
group.items.filter((item) => selected.has(item.id)).length === 3
);
}
export function replayConnectionsAttempt(
pack: NormalizedConnectionsPack,
submissions: ConnectionsSubmission[],
settings: ArcadeSessionSettings,
durationSeconds: number
): { complete: boolean; result: ArcadeRoundResult } {
const validItemIds = new Set(pack.content.flatMap((group) => group.items.map((item) => item.id)));
const solvedGroupIds = new Set<string>();
const incorrectSelections: string[][] = [];
let hintsUsed = 0;
let mistakes = 0;
let processed = 0;
for (const submission of submissions) {
if (solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes) break;
if (new Set(submission.itemIds).size !== 4 || submission.itemIds.some((id) => !validItemIds.has(id))) {
throw new Error("An attempt contains an invalid tile selection.");
}
processed += 1;
const group = groupForSelection(pack.content, submission.itemIds);
if (group && !solvedGroupIds.has(group.id)) {
solvedGroupIds.add(group.id);
} else {
if (settings.oneAwayFeedback && isOneAway(pack.content, submission.itemIds, solvedGroupIds)) {
hintsUsed += 1;
}
mistakes += 1;
incorrectSelections.push([...submission.itemIds]);
}
}
const complete = solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes;
const score = Math.max(0, solvedGroupIds.size * 100 - mistakes * 10);
const result: ArcadeRoundResult = {
outcome: solvedGroupIds.size === pack.content.length ? "WON" : "LOST",
score,
maxScore: 400,
accuracy: processed === 0 ? 0 : solvedGroupIds.size / processed,
durationSeconds,
mistakes,
hintsUsed,
groups: pack.content.map((group) => ({
groupId: group.id,
category: group.category,
items: group.items.map((item) => item.text),
explanation: group.explanation,
solved: solvedGroupIds.has(group.id),
})),
incorrectSelections,
};
return { complete, result };
}

View file

@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import { ArcadeImportError, parseConnectionsImport, parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
function validPack() {
return {
schemaVersion: 1,
type: "connections",
name: "Medication Connections",
description: "Four medication groups",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
category: `Category ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => `Item ${groupIndex + 1}-${itemIndex + 1}`),
explanation: `Explanation ${groupIndex + 1}`,
})),
};
}
describe("Connections imports", () => {
it("normalizes a valid board and creates stable ids", () => {
const result = parseConnectionsImport(JSON.stringify(validPack()));
expect(result.preview.itemCount).toBe(16);
expect(result.preview.itemCount).toBe(16);
expect(result.preview.normalized.content[0].id).toBe("group-1");
expect(result.preview.normalized.content[0].items[0].id).toBe("group-1-item-1");
});
it("rejects fences and surrounding prose", () => {
expect(() => parseConnectionsImport(`\`\`\`json\n${JSON.stringify(validPack())}\n\`\`\``)).toThrow(ArcadeImportError);
expect(() => parseConnectionsImport(`Here is the pack: ${JSON.stringify(validPack())}`)).toThrow(ArcadeImportError);
});
it("rejects duplicate tiles case-insensitively", () => {
const pack = validPack();
pack.content[1].items[0] = " item 1-1 ";
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("Every tile must be unique");
});
it("rejects unknown keys", () => {
const pack = { ...validPack(), extra: true };
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("does not match schema version 1");
});
it("reports long-tile warnings without changing the content", () => {
const pack = validPack();
pack.content[0].items[0] = "This intentionally long tile contains too many words";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings.length).toBeGreaterThan(0);
expect(result.preview.normalized.content[0].items[0].text).toBe(pack.content[0].items[0]);
});
it("does not treat a shared generic word as a similar category", () => {
const pack = validPack();
pack.content[0].category = "ACE Inhibitors";
pack.content[1].category = "Proton Pump Inhibitors";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings).not.toContain(
"Categories “ACE Inhibitors” and “Proton Pump Inhibitors” may be too similar."
);
});
it("previews multiple packs from one JSON array", () => {
const first = validPack();
const second = { ...validPack(), name: "Second board" };
const result = parseConnectionsImportBatch(JSON.stringify([first, second]));
expect(result.count).toBe(2);
expect(result.packs.map((pack) => pack.name)).toEqual(["Medication Connections", "Second board"]);
});
it("limits batch imports to ten packs", () => {
const batch = Array.from({ length: 11 }, () => validPack());
expect(() => parseConnectionsImportBatch(JSON.stringify(batch))).toThrow("between 1 and 10");
});
});

View file

@ -1,168 +0,0 @@
import { parseAndRepairJson } from "@/lib/jsonRepair";
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeImportPreview,
ArcadeImportBatchPreview,
ArcadeValidationReport,
NormalizedConnectionsPack,
} from "@/types/arcade";
export { ArcadeImportError } from "@/lib/arcade/arcadeImport";
function clean(value: string) {
return value.trim().replace(/\s+/g, " ");
}
function comparisonKey(value: string) {
return clean(value).toLocaleLowerCase();
}
function safeId(value: string, fallback: string) {
const id = value
.trim()
.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return id || fallback;
}
function findWarnings(pack: NormalizedConnectionsPack) {
const warnings: string[] = [];
const categoryKeys = pack.content.map((group) => comparisonKey(group.category));
for (const group of pack.content) {
for (const item of group.items) {
if (item.text.length > 40) {
warnings.push(`${item.text}” is longer than 40 characters and may be difficult to scan.`);
} else if (item.text.split(/\s+/).length > 4) {
warnings.push(`${item.text}” contains more than four words.`);
}
}
}
for (let left = 0; left < categoryKeys.length; left += 1) {
for (let right = left + 1; right < categoryKeys.length; right += 1) {
const a = categoryKeys[left];
const b = categoryKeys[right];
if (a.includes(b) || b.includes(a)) {
warnings.push(
`Categories “${pack.content[left].category}” and “${pack.content[right].category}” may be too similar.`
);
}
}
}
return [...new Set(warnings)];
}
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedConnectionsPack> {
const trimmed = rawJson.trim();
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
if (trimmed.includes("```") || (!isObject && !isArray)) {
throw new ArcadeImportError("Paste one raw JSON object or an array of pack objects without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const values = Array.isArray(repaired.data) ? repaired.data : [repaired.data];
if (values.length < 1 || values.length > 10) {
throw new ArcadeImportError("Import between 1 and 10 Connections packs at a time.");
}
const packs = values.map((value, index) => {
try {
const parsed = parseConnectionsImport(JSON.stringify(value));
return {
...parsed.preview,
wasRepaired: parsed.preview.wasRepaired || repaired.wasRepaired,
};
} catch (error) {
if (error instanceof ArcadeImportError) {
throw new ArcadeImportError(`Pack ${index + 1}: ${error.message}`, error.details);
}
throw error;
}
});
return { packs, count: packs.length, wasRepaired: repaired.wasRepaired };
}
export function parseConnectionsImport(rawJson: string): {
preview: ArcadeImportPreview<NormalizedConnectionsPack>;
report: ArcadeValidationReport;
} {
const trimmed = rawJson.trim();
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
throw new ArcadeImportError("Paste one raw JSON object without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const parsed = connectionsImportSchema.safeParse(repaired.data);
if (!parsed.success) {
throw new ArcadeImportError(
"The Connections pack does not match schema version 1.",
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
);
}
const categoryKeys = parsed.data.content.map((group) => comparisonKey(group.category));
if (new Set(categoryKeys).size !== categoryKeys.length) {
throw new ArcadeImportError("Every group must have a unique category name.");
}
const itemKeys = parsed.data.content.flatMap((group) => group.items.map(comparisonKey));
if (new Set(itemKeys).size !== itemKeys.length) {
throw new ArcadeImportError("Every tile must be unique across the entire board.");
}
const usedGroupIds = new Set<string>();
const normalized: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: clean(parsed.data.name),
description: parsed.data.description ? clean(parsed.data.description) : undefined,
settings: { ...parsed.data.settings },
content: parsed.data.content.map((group, groupIndex) => {
const fallbackId = `group-${groupIndex + 1}`;
const groupId = safeId(group.id ?? fallbackId, fallbackId);
if (usedGroupIds.has(groupId)) {
throw new ArcadeImportError(`Group id “${groupId}” is duplicated.`);
}
usedGroupIds.add(groupId);
return {
id: groupId,
category: clean(group.category),
explanation: group.explanation.trim(),
items: group.items.map((text, itemIndex) => ({
id: `${groupId}-item-${itemIndex + 1}`,
text: clean(text),
})),
};
}),
};
const warnings = findWarnings(normalized);
const report = { wasRepaired: repaired.wasRepaired, warnings };
return {
report,
preview: {
gameType: "connections",
name: normalized.name,
description: normalized.description,
categories: normalized.content.map((group) => group.category),
itemCount: 16,
wasRepaired: repaired.wasRepaired,
warnings,
normalized,
source: repaired.data,
},
};
}

View file

@ -1,34 +0,0 @@
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

@ -1,116 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,266 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -1,126 +0,0 @@
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

@ -1,22 +0,0 @@
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,60 +0,0 @@
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.
Return exactly one JSON object with no Markdown fence or commentary.
Use this schema:
{"schemaVersion":1,"type":"connections","name":"...","description":"...","settings":{"groupCount":4,"itemsPerGroup":4,"allowedMistakes":4},"content":[{"id":"group-1","category":"...","items":["...","...","...","..."],"explanation":"..."}]}
Rules:
- Generate exactly four groups of exactly four unique items.
- Every item must fit only its intended group within this board.
- Use specific, distinct category names and concise tiles of one to four words.
- Give every group a concise explanation.
- Do not create arbitrary leftover groups or use broad categories shared by most tiles.
- Base all content strictly on the study material below.
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: (rawJson: string) => unknown;
defaultInstructions: string;
}>;
export function getArcadeGameDefinition(gameType: ArcadeGameKey) {
return ARCADE_SERVER_REGISTRY[gameType];
}

View file

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { studyActivitySchema } from "./activitySchemas";
describe("study activity schema", () => {
it("accepts supported study activity types", () => {
expect(studyActivitySchema.safeParse({ type: "FLASHCARD" }).success).toBe(true);
expect(studyActivitySchema.safeParse({ type: "QUIZ_QUESTION" }).success).toBe(true);
});
it("rejects removed Arcade activity types", () => {
expect(studyActivitySchema.safeParse({ type: "ARCADE_GROUP" }).success).toBe(false);
expect(studyActivitySchema.safeParse({ type: "ARCADE_WORD" }).success).toBe(false);
});
});

View file

@ -1,7 +1,7 @@
import { z } from "zod";
export const studyActivitySchema = z.object({
type: z.enum(["FLASHCARD", "QUIZ_QUESTION", "ARCADE_GROUP"]),
type: z.enum(["FLASHCARD", "QUIZ_QUESTION"]),
});
export type StudyActivityType = z.infer<typeof studyActivitySchema>["type"];

View file

@ -1,107 +0,0 @@
import { z } from "zod";
const connectionGroupSchema = z.object({
id: z.string().trim().min(1).optional(),
category: z.string().trim().min(1).max(100),
items: z.tuple([
z.string().trim().min(1),
z.string().trim().min(1),
z.string().trim().min(1),
z.string().trim().min(1),
]),
explanation: z.string().trim().min(1).max(1000),
// Accepted for compatibility with early packs, but no longer used or generated.
difficulty: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(),
}).strict();
export const connectionsImportSchema = z.object({
schemaVersion: z.literal(1),
type: z.literal("connections"),
name: z.string().trim().min(1).max(150),
description: z.string().trim().max(500).optional(),
settings: z.object({
groupCount: z.literal(4),
itemsPerGroup: z.literal(4),
allowedMistakes: z.number().int().min(1).max(8),
}).strict(),
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.union([z.literal("connections"), z.literal("crossword")]),
rawJson: z.string().min(1),
});
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(),
warningsAcknowledged: z.boolean().default(false),
});
export const arcadePackUpdateSchema = z.object({
name: z.string().trim().min(1).max(150),
});
export const arcadeAttemptCreateSchema = z.object({
mode: z.literal("CLASSIC").default("CLASSIC"),
durationSeconds: z.number().int().min(0).max(86400),
seed: z.string().min(1).max(100),
settings: z.object({
allowedMistakes: z.number().int().min(1).max(8),
oneAwayFeedback: z.boolean(),
}).strict(),
submissions: z.array(z.object({
itemIds: z.array(z.string().min(1)).length(4),
elapsedMs: z.number().int().min(0).max(86400000),
}).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

@ -2,6 +2,18 @@ import { describe, expect, it } from "vitest";
import { summarizeActivityBuckets, summarizeActivityRows } from "./activityService";
describe("summarizeActivityRows", () => {
it("summarizes the remaining activity categories without Arcade fields", () => {
const now = new Date("2026-08-07T18:00:00.000Z");
const summary = summarizeActivityBuckets([
{ date: "2026-08-07", type: "FLASHCARD", count: 2 },
{ date: "2026-08-07", type: "QUIZ_QUESTION", count: 3 },
], now);
expect(summary).toMatchObject({ flashcards: 2, questions: 3, currentStreak: 0 });
expect(summary.days.find((day) => day.date === summary.today)).toMatchObject({ flashcards: 2, questions: 3, total: 5 });
expect(summary).not.toHaveProperty("arcade");
});
it("keeps a 53-week display while preserving a longer current streak", () => {
const now = new Date("2026-08-07T18:00:00.000Z");
const activities = Array.from({ length: 400 }, (_, age) =>

View file

@ -10,7 +10,6 @@ export interface DailyActivity {
date: string;
flashcards: number;
questions: number;
arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@ -21,7 +20,6 @@ export interface ActivitySummary {
activeDays: number;
flashcards: number;
questions: number;
arcade: number;
currentStreak: number;
}
@ -48,7 +46,7 @@ function getLevel(total: number): 0 | 1 | 2 | 3 | 4 {
}
function emptyDay(date: string): DailyActivity {
return { date, flashcards: 0, questions: 0, arcade: 0, total: 0, level: 0 };
return { date, flashcards: 0, questions: 0, total: 0, level: 0 };
}
export async function recordActivity(type: StudyActivityType) {
@ -96,7 +94,6 @@ export function summarizeActivityBuckets(
const day = totals.get(date) ?? emptyDay(date);
if (activity.type === "FLASHCARD") day.flashcards += activity.count;
if (activity.type === "QUIZ_QUESTION") day.questions += activity.count;
if (activity.type === "ARCADE_GROUP") day.arcade += activity.count;
day.total += activity.count;
day.level = getLevel(day.total);
totals.set(date, day);
@ -109,7 +106,6 @@ export function summarizeActivityBuckets(
const visibleDays = days.filter((day) => day.date <= today && day.date <= endDate);
const flashcards = visibleDays.reduce((sum, day) => sum + day.flashcards, 0);
const questions = visibleDays.reduce((sum, day) => sum + day.questions, 0);
const arcade = visibleDays.reduce((sum, day) => sum + day.arcade, 0);
let streakDate = (totals.get(today)?.total ?? 0) >= STREAK_THRESHOLD
? today
@ -126,7 +122,6 @@ export function summarizeActivityBuckets(
activeDays: visibleDays.filter((day) => day.total > 0).length,
flashcards,
questions,
arcade,
currentStreak,
};
}

View file

@ -1,228 +0,0 @@
import { prisma } from "@/lib/db";
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import { replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
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: {
id: string;
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
completedAt: Date;
resultsJson?: string;
}): ArcadeAttemptSummary {
return {
id: attempt.id,
score: attempt.score,
maxScore: attempt.maxScore,
accuracy: attempt.accuracy,
durationSeconds: attempt.durationSeconds,
mistakes: attempt.mistakes,
completedAt: attempt.completedAt.toISOString(),
...(attempt.resultsJson ? { results: JSON.parse(attempt.resultsJson) } : {}),
};
}
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: {
classId: string;
gameType: ArcadeGameKey;
rawJson: string;
name?: string;
names?: string[];
warningsAcknowledged: boolean;
}) {
const classItem = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!classItem) throw new Error("Class not found");
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.");
}
if (batch.packs.some((preview) => preview.warnings.length > 0) && !input.warningsAcknowledged) {
throw new Error("Import warnings must be acknowledged before saving.");
}
const maxOrder = await prisma.arcadePack.aggregate({
where: { classId: input.classId, gameType: input.gameType },
_max: { sortOrder: true },
});
return prisma.$transaction(async (transaction) => {
const created = [];
for (const [index, preview] of batch.packs.entries()) {
created.push(await transaction.arcadePack.create({
data: {
classId: input.classId,
gameType: input.gameType,
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.source),
normalizedJson: JSON.stringify(preview.normalized),
validationReportJson: JSON.stringify({ wasRepaired: preview.wasRepaired, warnings: preview.warnings }),
sortOrder: (maxOrder._max.sortOrder ?? -1) + index + 1,
},
}));
}
return created;
});
}
export async function listArcadePacks(classId: string, gameType: ArcadeGameKey): Promise<ArcadePackSummary[]> {
const packs = await prisma.arcadePack.findMany({
where: { classId, gameType },
orderBy: { sortOrder: "asc" },
include: { attempts: { orderBy: { completedAt: "desc" } } },
});
return packs.map((pack) => {
const normalized = JSON.parse(pack.normalizedJson) as NormalizedArcadePack;
const isConnections = normalized.type === "connections";
return {
id: pack.id,
classId: pack.classId,
gameType: pack.gameType as ArcadeGameKey,
name: pack.name,
description: pack.description,
schemaVersion: pack.schemaVersion,
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,
};
});
}
export async function getArcadePack(id: string) {
const pack = await prisma.arcadePack.findUnique({
where: { id },
include: { class: { select: { slug: true, name: true } } },
});
if (!pack) return null;
return {
...pack,
gameType: pack.gameType as ArcadeGameKey,
normalized: JSON.parse(pack.normalizedJson) as NormalizedArcadePack,
validationReport: JSON.parse(pack.validationReportJson),
};
}
export async function updateArcadePack(id: string, name: string) {
const existing = await prisma.arcadePack.findUnique({ where: { id }, select: { id: true } });
if (!existing) return null;
return prisma.arcadePack.update({ where: { id }, data: { name } });
}
export async function deleteArcadePack(id: string) {
const existing = await prisma.arcadePack.findUnique({ where: { id }, select: { id: true } });
if (!existing) return false;
await prisma.arcadePack.delete({ where: { id } });
return true;
}
export async function listArcadeAttempts(arcadePackId: string) {
const attempts = await prisma.arcadeAttempt.findMany({
where: { arcadePackId },
orderBy: { completedAt: "desc" },
take: 20,
});
return attempts.map(attemptSummary);
}
export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAttemptCreateInput) {
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,
input.settings,
input.durationSeconds
);
if (!replay.complete) throw new Error("Only completed Connections rounds can be saved.");
const solvedGroups = replay.result.groups.filter((group) => group.solved).length;
return prisma.$transaction(async (transaction) => {
const attempt = await transaction.arcadeAttempt.create({
data: {
arcadePackId,
mode: input.mode,
score: replay.result.score,
maxScore: replay.result.maxScore,
accuracy: replay.result.accuracy,
durationSeconds: replay.result.durationSeconds,
mistakes: replay.result.mistakes,
hintsUsed: replay.result.hintsUsed,
settingsJson: JSON.stringify(input.settings),
resultsJson: JSON.stringify(replay.result),
seed: input.seed,
},
});
if (solvedGroups > 0) {
await transaction.studyActivity.createMany({
data: Array.from({ length: solvedGroups }, () => ({ type: "ARCADE_GROUP" })),
});
}
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,6 @@
import { prisma } from "@/lib/db";
import { ARCADE_SERVER_REGISTRY } from "@/lib/arcade/registry";
export type LlmInstructionType = "flashcards" | "quizzes" | "connections" | "crossword";
export type LlmInstructionType = "flashcards" | "quizzes";
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
@ -53,9 +52,7 @@ 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 };
if (type === "connections") return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
return { key: "llmInstructionsCrossword", value: ARCADE_SERVER_REGISTRY.crossword.defaultInstructions };
return { key: "llmInstructionsQuizzes", value: DEFAULT_LLM_INSTRUCTIONS_QUIZZES };
}
export async function getLlmInstructions(type: LlmInstructionType): Promise<string> {

View file

@ -1,249 +0,0 @@
export type ArcadeGameKey = "connections" | "crossword";
export type CrosswordSize = "mini" | "standard" | "large" | "extra-large";
export interface ConnectionsImportGroup {
id?: string;
category: string;
items: [string, string, string, string];
explanation: string;
difficulty?: 1 | 2 | 3 | 4;
}
export interface ConnectionsImportData {
schemaVersion: 1;
type: "connections";
name: string;
description?: string;
settings: {
groupCount: 4;
itemsPerGroup: 4;
allowedMistakes: number;
};
content: ConnectionsImportGroup[];
}
export interface NormalizedConnectionsItem {
id: string;
text: string;
}
export interface NormalizedConnectionsGroup {
id: string;
category: string;
items: NormalizedConnectionsItem[];
explanation: string;
}
export interface NormalizedConnectionsPack {
schemaVersion: 1;
type: "connections";
name: string;
description?: string;
settings: { groupCount: 4; itemsPerGroup: 4; allowedMistakes: number };
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<TPack extends NormalizedArcadePack = NormalizedArcadePack> {
gameType: ArcadeGameKey;
name: string;
description?: string;
categories: string[];
itemCount: number;
wasRepaired: boolean;
warnings: string[];
normalized: TPack;
source: unknown;
layoutPreviews?: CrosswordLayoutPreview[];
}
export interface ArcadeImportBatchPreview<TPack extends NormalizedArcadePack = NormalizedArcadePack> {
packs: ArcadeImportPreview<TPack>[];
count: number;
wasRepaired: boolean;
}
export interface ArcadeSessionSettings {
allowedMistakes: number;
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;
}
export interface ArcadeActionLog {
submissions: ConnectionsSubmission[];
}
export interface ArcadeGroupResult {
groupId: string;
category: string;
items: string[];
explanation: string;
solved: boolean;
}
export interface ArcadeRoundResult {
outcome: "WON" | "LOST";
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
hintsUsed: number;
groups: ArcadeGroupResult[];
incorrectSelections: string[][];
}
export interface ArcadeAttemptSummary {
id: string;
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
completedAt: string;
results?: ArcadeRoundResult | CrosswordRoundResult;
}
export interface ArcadePackSummary {
id: string;
classId: string;
gameType: ArcadeGameKey;
name: string;
description: string | null;
schemaVersion: number;
itemCount: number;
defaultAllowedMistakes: number;
defaultInstantCheck: boolean;
defaultAllowHints: boolean;
bestScore: number | null;
latestAttempt: ArcadeAttemptSummary | null;
}

View file

@ -0,0 +1,98 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { afterEach, describe, expect, it } from "vitest";
import {
applyCommittedMigrations,
createDisposableDatabase,
disposeDisposableDatabase,
type DisposableDatabase,
} from "./helpers/testDatabase";
const REMOVAL_MIGRATION = "20260808100000_remove_arcade";
const databases: DisposableDatabase[] = [];
afterEach(() => {
for (const database of databases.splice(0)) {
disposeDisposableDatabase(database);
}
});
describe("Arcade removal migration", () => {
it("removes Arcade data while preserving the rest of the study database", () => {
const database = createDisposableDatabase();
databases.push(database);
applyCommittedMigrations(database, { through: "20260807092000_add_progress_revisions" });
const sqlite = new Database(database.databasePath);
try {
sqlite.pragma("foreign_keys = ON");
sqlite.exec(`
INSERT INTO "Class" ("id", "slug", "name")
VALUES ('class-arcade-removal', 'arcade-removal', 'Preserved class');
INSERT INTO "MaterialGroup" ("id", "classId", "name", "type") VALUES
('group-deck-arcade-removal', 'class-arcade-removal', 'Preserved deck group', 'DECK'),
('group-quiz-arcade-removal', 'class-arcade-removal', 'Preserved quiz group', 'QUIZ');
INSERT INTO "Deck" ("id", "classId", "groupId", "name")
VALUES ('deck-arcade-removal', 'class-arcade-removal', 'group-deck-arcade-removal', 'Preserved deck');
INSERT INTO "Flashcard" ("id", "deckId", "front", "back")
VALUES ('card-arcade-removal', 'deck-arcade-removal', 'Preserved front', 'Preserved back');
INSERT INTO "QuizSet" ("id", "classId", "groupId", "name")
VALUES ('quiz-arcade-removal', 'class-arcade-removal', 'group-quiz-arcade-removal', 'Preserved quiz');
INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category")
VALUES ('question-arcade-removal', 'quiz-arcade-removal', 'MULTIPLE_CHOICE', 'Preserved prompt', 'Preserved rationale', 'Preserved category');
INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect")
VALUES ('option-arcade-removal', 'question-arcade-removal', 'Preserved option', 1);
INSERT INTO "StudyProgress" ("id", "contentType", "deckId", "mode", "orderJson", "updatedAt", "sessionId", "revision") VALUES
('deck-progress-arcade-removal', 'DECK', 'deck-arcade-removal', 'SEQUENTIAL', '["card-arcade-removal"]', CURRENT_TIMESTAMP, 'session-deck', 2),
('quiz-progress-arcade-removal', 'QUIZ', NULL, 'SEQUENTIAL', '["question-arcade-removal"]', CURRENT_TIMESTAMP, 'session-quiz', 3);
UPDATE "StudyProgress"
SET "quizSetId" = 'quiz-arcade-removal'
WHERE "id" = 'quiz-progress-arcade-removal';
INSERT INTO "ShareLink" ("id", "targetType", "groupId")
VALUES ('share-arcade-removal', 'GROUP', 'group-quiz-arcade-removal');
INSERT INTO "Setting" ("key", "value") VALUES
('llmInstructionsFlashcards', 'keep this'),
('llmInstructionsConnections', 'remove this'),
('llmInstructionsCrossword', 'remove this');
INSERT INTO "StudyActivity" ("id", "type") VALUES
('activity-flashcard', 'FLASHCARD'),
('activity-question', 'QUIZ_QUESTION'),
('activity-group', 'ARCADE_GROUP'),
('activity-word', 'ARCADE_WORD');
INSERT INTO "ArcadePack"
("id", "classId", "gameType", "name", "schemaVersion", "sourceJson", "normalizedJson", "validationReportJson", "updatedAt")
VALUES ('arcade-pack', 'class-arcade-removal', 'connections', 'Removed pack', 1, '{}', '{}', '{}', CURRENT_TIMESTAMP);
INSERT INTO "ArcadeAttempt"
("id", "arcadePackId", "mode", "score", "maxScore", "accuracy", "durationSeconds", "mistakes", "hintsUsed", "settingsJson", "resultsJson", "seed")
VALUES ('arcade-attempt', 'arcade-pack', 'CLASSIC', 0, 400, 0, 0, 0, 0, '{}', '{}', 'seed');
`);
sqlite.exec(readFileSync(
path.join(process.cwd(), "prisma", "migrations", REMOVAL_MIGRATION, "migration.sql"),
"utf8"
));
expect(sqlite.prepare('SELECT "name" FROM "Class" WHERE "id" = ?').get("class-arcade-removal")).toEqual({ name: "Preserved class" });
expect(sqlite.prepare('SELECT "name" FROM "Deck" WHERE "id" = ?').get("deck-arcade-removal")).toEqual({ name: "Preserved deck" });
expect(sqlite.prepare('SELECT "name" FROM "MaterialGroup" WHERE "id" = ?').get("group-deck-arcade-removal")).toEqual({ name: "Preserved deck group" });
expect(sqlite.prepare('SELECT "name" FROM "QuizSet" WHERE "id" = ?').get("quiz-arcade-removal")).toEqual({ name: "Preserved quiz" });
expect(sqlite.prepare('SELECT "text" FROM "AnswerOption" WHERE "id" = ?').get("option-arcade-removal")).toEqual({ text: "Preserved option" });
expect(sqlite.prepare('SELECT COUNT(*) AS count FROM "StudyProgress"').get()).toEqual({ count: 2 });
expect(sqlite.prepare('SELECT "targetType" FROM "ShareLink" WHERE "id" = ?').get("share-arcade-removal")).toEqual({ targetType: "GROUP" });
expect(sqlite.prepare('SELECT "key" FROM "Setting" ORDER BY "key"').all()).toEqual([
{ key: "llmInstructionsFlashcards" },
]);
expect(sqlite.prepare('SELECT "type" FROM "StudyActivity" ORDER BY "type"').all()).toEqual([
{ type: "FLASHCARD" },
{ type: "QUIZ_QUESTION" },
]);
expect(sqlite.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get("table", "ArcadePack")).toBeUndefined();
expect(sqlite.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get("table", "ArcadeAttempt")).toBeUndefined();
expect(sqlite.pragma("foreign_key_check")).toEqual([]);
expect(sqlite.pragma("integrity_check", { simple: true })).toBe("ok");
} finally {
sqlite.close();
}
});
});