Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
7bd0a9aad4 Limit CI linting to changed source files
All checks were successful
Verify and publish container / build-and-push (push) Successful in 1m10s
2026-08-08 18:54:39 -07:00
e8dffc9692 Remove obsolete project artifacts
Some checks failed
Verify and publish container / build-and-push (push) Failing after 33s
2026-08-08 18:50:16 -07:00
5db7fc8afd Harden migration preflight and startup adoption checks
All checks were successful
Verify and publish container / build-and-push (push) Successful in 1m26s
2026-08-08 18:17:17 -07:00
931e674814 Fixed CL failure
All checks were successful
Verify and publish container / build-and-push (push) Successful in 1m9s
2026-08-07 19:59:02 -07:00
a95f7c1d18 Harden changed-file lint workflow
Some checks failed
Verify and publish container / build-and-push (push) Failing after 1m0s
2026-08-07 19:50:38 -07:00
e86b0b21df Merge pull request 'Refactor Study Desk application structure' (#3) from codex/majorfixes into main
Some checks failed
Verify and publish container / build-and-push (push) Failing after 34s
Reviewed-on: #3
2026-08-07 19:33:37 -07:00
63 changed files with 424 additions and 7625 deletions

View file

@ -11,6 +11,9 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
# The changed-file lint step compares the pushed commit with its parent.
fetch-depth: 2
- name: Log into Local Registry
run: |
@ -33,24 +36,35 @@ jobs:
- name: Lint changed source files
run: |
FILES=$(git diff --name-only HEAD^ HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs')
if [ -n "$FILES" ]; then npx eslint $FILES; fi
if BASE=$(git rev-parse HEAD^ 2>/dev/null); then
git diff --name-only --diff-filter=ACMR -z "$BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs' |
xargs -0 -r npx eslint
else
git ls-files -z -- '*.ts' '*.tsx' '*.js' '*.mjs' |
xargs -0 -r npx eslint
fi
- name: Build and smoke production image
run: |
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}"
CI_SECRET=$(openssl rand -hex 32)
SMOKE_CONTAINER="study-smoke-$(openssl rand -hex 8)"
cleanup_smoke() {
docker rm -f "$SMOKE_CONTAINER" > /dev/null 2>&1 || true
}
trap cleanup_smoke EXIT
docker build -t "$IMAGE_SHA" .
docker run -d --name study-smoke -p 3000:3726 -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
docker run -d --name "$SMOKE_CONTAINER" -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
for attempt in $(seq 1 30); do
if [ "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" ]; then break; fi
if [ "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy" ]; then break; fi
sleep 2
done
test "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy"
curl --fail http://127.0.0.1:3000/login > /dev/null
curl --fail http://127.0.0.1:3000/api/auth/setup-status | grep '"setupRequired":true'
docker rm -f study-smoke
test "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy"
docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/login').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/api/auth/setup-status').then(async r => { const body = await r.json(); if (!r.ok || body.setupRequired !== true) process.exit(1) }).catch(() => process.exit(1))"
cleanup_smoke
trap - EXIT
docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest"
docker push "$IMAGE_SHA"
docker push "$IMAGE_PATH:latest"

View file

@ -61,12 +61,18 @@ The material-group preflight reports one of four states:
- `APPLY`: migration history is complete and the group schema is absent; run
`prisma migrate deploy` normally.
- `ADOPT`: prior migrations are tracked and the database exactly matches the
intended group schema. After reviewing the verified backup, explicitly run
intended schema of one or more unrecorded committed migrations. This includes
recovery from P3018 when an already schema-pushed table or column exists.
After reviewing the verified backup, explicitly run
`npm run db:preflight -- --resolve --backup <backup-path>`.
- `CURRENT`: migration and schema already agree.
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
`db push`, reset, edit applied migrations, or mark anything applied.
The production entrypoint runs this preflight before `prisma migrate deploy`.
It refuses `ADOPT` and `CONFLICT` states without changing migration history;
adoption always remains an explicit backup-gated operator action.
Restore drill: stop Study Desk, keep the damaged database as evidence, restore
the verified backup to a new path, run SQLite `integrity_check` plus
`npm run db:preflight`, start the same prior application image against the

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

@ -8,5 +8,6 @@ if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SE
exit 1
fi
node scripts/migration-preflight.mjs --startup
./node_modules/.bin/prisma migrate deploy
exec node server.js

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

@ -38,10 +38,14 @@ function hasUniqueIndex(database, table, indexName, column) {
return columns.length === 1 && columns[0].name === column;
}
function schemaSnapshot(database) {
function schemaSnapshot(database, includeMigrationTable = false) {
const tables = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name"
`SELECT name FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
${includeMigrationTable ? "" : "AND name <> '_prisma_migrations'"}
ORDER BY name`
)
.all()
.map((row) => row.name);
@ -113,14 +117,20 @@ export function inspectMigrationState(databasePath) {
}
const database = new Database(databasePath, { readonly: true, fileMustExist: true });
try {
const migrationNames = tableExists(database, "_prisma_migrations")
const migrationRecords = tableExists(database, "_prisma_migrations")
? database
.prepare(
'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL'
'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"'
)
.all()
.map((row) => row.migration_name)
: [];
const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null);
const migrationNames = activeRecords
.filter((row) => row.finished_at !== null)
.map((row) => row.migration_name);
const failedMigrationNames = activeRecords
.filter((row) => row.finished_at === null)
.map((row) => row.migration_name);
const committedMigrations = readdirSync(
path.join(process.cwd(), "prisma", "migrations"),
{ withFileTypes: true }
@ -131,9 +141,7 @@ export function inspectMigrationState(databasePath) {
const priorMigrations = committedMigrations.filter(
(name) => name < GROUP_MIGRATION
);
const laterMigrations = committedMigrations.filter(
(name) => name > GROUP_MIGRATION
);
const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION);
const artifacts = {
materialGroup: tableExists(database, "MaterialGroup"),
@ -168,19 +176,12 @@ export function inspectMigrationState(databasePath) {
"groupId"
),
};
const values = Object.values(artifacts);
const groupArtifactsPresent = values.every(Boolean);
const groupArtifactsAbsent = values.every((value) => !value);
const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION);
const priorHistoryComplete = priorMigrations.every((name) =>
migrationNames.includes(name)
);
const knownMigrationNames = new Set(committedMigrations);
const historyHasUnknown = migrationNames.some(
(name) => !knownMigrationNames.has(name)
);
const laterHistoryPresent = laterMigrations.some((name) =>
migrationNames.includes(name)
const historyHasUnknown = activeRecords.some(
(row) => !knownMigrationNames.has(row.migration_name)
);
const actualSchema = schemaSnapshot(database);
const matches = (expected) =>
@ -189,42 +190,66 @@ export function inspectMigrationState(databasePath) {
const adoptedGroupSchemaExact = matches(
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
);
const appliedPrefix = committedMigrations.filter((name, index) =>
committedMigrations.slice(0, index + 1).every((candidate) =>
migrationNames.includes(candidate)
)
);
const appliedHistoryExact =
let historyPrefixLength = 0;
while (
historyPrefixLength < committedMigrations.length &&
migrationNames.includes(committedMigrations[historyPrefixLength])
) {
historyPrefixLength += 1;
}
const historyIsExactPrefix =
!historyHasUnknown &&
migrationNames.length === appliedPrefix.length &&
migrationNames.every((name) => appliedPrefix.includes(name));
migrationNames.length === historyPrefixLength &&
migrationNames.every((name) =>
committedMigrations.slice(0, historyPrefixLength).includes(name)
);
const trackedSchemaExact =
appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix));
historyIsExactPrefix &&
matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength)));
let schemaPrefixLength = -1;
for (let index = 0; index <= committedMigrations.length; index += 1) {
if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) {
schemaPrefixLength = index;
break;
}
}
const migrationsToAdopt =
schemaPrefixLength > historyPrefixLength
? committedMigrations.slice(historyPrefixLength, schemaPrefixLength)
: [];
const failuresAreAdoptable = failedMigrationNames.every((name) =>
migrationsToAdopt.includes(name)
);
let classification = "CONFLICT";
if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT";
else if (
!groupMigrationApplied &&
!laterHistoryPresent &&
if (
historyIsExactPrefix &&
failedMigrationNames.length === 0 &&
schemaPrefixLength === historyPrefixLength
) {
classification =
historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY";
} else if (
historyIsExactPrefix &&
priorHistoryComplete &&
migrationNames.length === priorMigrations.length &&
groupArtifactsAbsent &&
preGroupSchemaExact
) classification = "APPLY";
else if (
!groupMigrationApplied &&
!laterHistoryPresent &&
priorHistoryComplete &&
migrationNames.length === priorMigrations.length &&
groupArtifactsPresent &&
adoptedGroupSchemaExact
) classification = "ADOPT";
historyPrefixLength >= groupMigrationIndex &&
schemaPrefixLength > historyPrefixLength &&
failuresAreAdoptable
) {
classification = "ADOPT";
}
return {
classification,
artifacts,
migrationNames,
failedMigrationNames,
migrationsToAdopt,
priorHistoryComplete,
schemaMigrationPrefix:
schemaPrefixLength >= 0
? committedMigrations[schemaPrefixLength - 1] ?? null
: null,
exactSchema: {
preGroup: preGroupSchemaExact,
adoptedGroup: adoptedGroupSchemaExact,
@ -249,17 +274,20 @@ function verifyBackup(backupPath, sourcePath) {
if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
throw new Error("Backup failed SQLite integrity_check");
}
if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) {
if (
JSON.stringify(schemaSnapshot(backup, true)) !==
JSON.stringify(schemaSnapshot(source, true))
) {
throw new Error("Backup schema does not match the source database");
}
const sourceCounts = Object.fromEntries(
schemaSnapshot(source).map(({ table }) => [
schemaSnapshot(source, true).map(({ table }) => [
table,
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
);
const backupCounts = Object.fromEntries(
schemaSnapshot(backup).map(({ table }) => [
schemaSnapshot(backup, true).map(({ table }) => [
table,
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
@ -273,11 +301,11 @@ function verifyBackup(backupPath, sourcePath) {
}
}
function resolveMigration(databaseUrl) {
function resolveMigration(databaseUrl, migrationName) {
const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
const result = spawnSync(
process.execPath,
[cli, "migrate", "resolve", "--applied", GROUP_MIGRATION],
[cli, "migrate", "resolve", "--applied", migrationName],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: databaseUrl },
@ -292,7 +320,16 @@ function resolveMigration(databaseUrl) {
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const databaseUrl = process.env.DATABASE_URL;
const databasePath = databasePathFromUrl(databaseUrl);
const state = inspectMigrationState(databasePath);
const startup = process.argv.includes("--startup");
const state =
startup && !existsSync(databasePath)
? {
classification: "EMPTY",
migrationNames: [],
failedMigrationNames: [],
migrationsToAdopt: [],
}
: inspectMigrationState(databasePath);
console.info(JSON.stringify(state, null, 2));
if (process.argv.includes("--resolve")) {
@ -304,8 +341,17 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
databasePath
);
resolveMigration(databaseUrl);
console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`);
for (const migrationName of state.migrationsToAdopt) {
resolveMigration(databaseUrl, migrationName);
}
console.info(
`Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.`
);
} else if (startup && state.classification === "ADOPT") {
console.error(
"Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup <backup-path>."
);
process.exitCode = 3;
} else if (state.classification === "CONFLICT") {
process.exitCode = 2;
}

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'
export type PrismaClientOptions = ({
/**
* Options common to all variants of `PrismaClientOptions`, regardless of whether you connect to your database through a driver adapter or through Prisma Accelerate.
* Instance of a Driver Adapter, e.g., like one provided by `@prisma/adapter-pg`.
*/
export interface PrismaClientBaseOptions {
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();
}
});
});

View file

@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { copyFileSync, readFileSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { afterEach, describe, expect, it } from "vitest";
@ -29,10 +29,10 @@ function create(options: { excludeGroup?: boolean } = {}) {
return database;
}
function inspect(database: DisposableDatabase) {
function inspect(database: DisposableDatabase, args: string[] = []) {
const result = spawnSync(
process.execPath,
[path.join(process.cwd(), "scripts", "migration-preflight.mjs")],
[path.join(process.cwd(), "scripts", "migration-preflight.mjs"), ...args],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: database.databaseUrl },
@ -42,7 +42,29 @@ function inspect(database: DisposableDatabase) {
return { status: result.status, output: JSON.parse(result.stdout) };
}
function migrationSql(migrationName: string) {
return readFileSync(
path.join(
process.cwd(),
"prisma",
"migrations",
migrationName,
"migration.sql"
),
"utf8"
);
}
describe("material group migration preflight", () => {
it("allows startup when a fresh database does not exist yet", () => {
const database = createDisposableDatabase();
databases.push(database);
expect(inspect(database, ["--startup"])).toMatchObject({
status: 0,
output: { classification: "EMPTY", migrationsToAdopt: [] },
});
});
it("preserves populated pre-group rows and relationships", () => {
const database = create({ excludeGroup: true });
const client = new Database(database.databasePath);
@ -93,6 +115,105 @@ describe("material group migration preflight", () => {
});
});
it("blocks startup and explicitly adopts a fully schema-pushed database after P3018", () => {
const database = create({ excludeGroup: true });
const missingMigrations = [
GROUP_MIGRATION,
"20260807091000_add_quiz_attempt_snapshot",
"20260807092000_add_progress_revisions",
];
const client = new Database(database.databasePath);
try {
for (const migrationName of missingMigrations) {
client.exec(migrationSql(migrationName));
}
client.prepare(`
INSERT INTO "_prisma_migrations"
("id", "checksum", "finished_at", "migration_name", "logs", "applied_steps_count")
VALUES (?, ?, NULL, ?, ?, 0)
`).run("failed-group-migration", "failed", GROUP_MIGRATION, "P3018");
} finally {
client.close();
}
const startup = inspect(database, ["--startup"]);
expect(startup).toMatchObject({
status: 3,
output: {
classification: "ADOPT",
migrationsToAdopt: missingMigrations,
},
});
const backupPath = path.join(database.directory, "adoption-backup.test.db");
copyFileSync(database.databasePath, backupPath);
const resolve = spawnSync(
process.execPath,
[
path.join(process.cwd(), "scripts", "migration-preflight.mjs"),
"--resolve",
"--backup",
backupPath,
],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: database.databaseUrl },
encoding: "utf8",
}
);
expect(resolve.status, resolve.stderr).toBe(0);
const deploy = spawnSync(
process.execPath,
[
path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"),
"migrate",
"deploy",
],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: database.databaseUrl },
encoding: "utf8",
}
);
expect(deploy.status, `${deploy.stdout}\n${deploy.stderr}`).toBe(0);
expect(inspect(database)).toMatchObject({
status: 0,
output: { classification: "CURRENT", migrationsToAdopt: [] },
});
}, 30_000);
it("can resume exact adoption after some missing migrations were recorded", () => {
const database = create({ excludeGroup: true });
const client = new Database(database.databasePath);
try {
for (const migrationName of [
GROUP_MIGRATION,
"20260807091000_add_quiz_attempt_snapshot",
"20260807092000_add_progress_revisions",
]) {
client.exec(migrationSql(migrationName));
}
client.prepare(`
INSERT INTO "_prisma_migrations"
("id", "checksum", "finished_at", "migration_name", "applied_steps_count")
VALUES (?, ?, current_timestamp, ?, 1)
`).run("adopted-group-migration", "adopted", GROUP_MIGRATION);
} finally {
client.close();
}
expect(inspect(database, ["--startup"])).toMatchObject({
status: 3,
output: {
classification: "ADOPT",
migrationsToAdopt: [
"20260807091000_add_quiz_attempt_snapshot",
"20260807092000_add_progress_revisions",
],
},
});
});
it("rejects a partial schema without modifying it", () => {
const database = create();
const client = new Database(database.databasePath);