Compare commits
6 commits
codex/majo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd0a9aad4 | |||
| e8dffc9692 | |||
| 5db7fc8afd | |||
| 931e674814 | |||
| a95f7c1d18 | |||
| e86b0b21df |
63 changed files with 424 additions and 7625 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
prisma/migrations/20260808100000_remove_arcade/migration.sql
Normal file
10
prisma/migrations/20260808100000_remove_arcade/migration.sql
Normal 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";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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" }} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="arcade-route">{children}</div>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`));
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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` : "Let’s 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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
export const ARCADE_GAMES = [
|
||||
{
|
||||
key: "connections",
|
||||
name: "Connections",
|
||||
path: "connections",
|
||||
available: true,
|
||||
estimatedMinutes: "5–10 min",
|
||||
description: "Find four hidden relationships among sixteen terms.",
|
||||
},
|
||||
{
|
||||
key: "crossword",
|
||||
name: "Crossword",
|
||||
path: "crossword",
|
||||
available: true,
|
||||
estimatedMinutes: "10–45 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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -80,12 +80,12 @@ export type PrismaVersion = {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prisma Client JS version: 7.9.1
|
||||
* Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
|
||||
* Prisma Client JS version: 7.8.0
|
||||
* Query Engine version: 3c6e192761c0362d496ed980de936e2f3cebcd3a
|
||||
*/
|
||||
export const prismaVersion: PrismaVersion = {
|
||||
client: "7.9.1",
|
||||
engine: "e922089b7d7502aff4249d5da3420f6fa55fc6ad"
|
||||
client: "7.8.0",
|
||||
engine: "3c6e192761c0362d496ed980de936e2f3cebcd3a"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,19 +155,6 @@ export type Subset<T, U> = {
|
|||
[key in keyof T]: key extends keyof U ? T[key] : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved type of the argument passed to the `PrismaClient` constructor.
|
||||
*
|
||||
* When called without a narrower options type (the common case), this resolves
|
||||
* to `PrismaClientOptions` directly, which produces a clear TypeScript error
|
||||
* message (`not assignable to parameter of type 'PrismaClientOptions'`) when
|
||||
* the argument is missing or incomplete. When the user supplies a narrower
|
||||
* options type (e.g. via a literal), it falls back to `Subset` to keep
|
||||
* filtering out unknown properties.
|
||||
*/
|
||||
export type PrismaClientConstructorArgs<Options extends PrismaClientOptions> =
|
||||
[PrismaClientOptions] extends [Options] ? PrismaClientOptions : Subset<Options, PrismaClientOptions>;
|
||||
|
||||
/**
|
||||
* SelectSubset
|
||||
* @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
|
||||
|
|
@ -200,7 +187,7 @@ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
|
|||
export type XOR<T, U> =
|
||||
T extends object ?
|
||||
U extends object ?
|
||||
((Without<T, U> & U) | (Without<U, T> & T)) & object
|
||||
(Without<T, U> & U) | (Without<U, T> & T)
|
||||
: U : T
|
||||
|
||||
|
||||
|
|
@ -409,8 +396,6 @@ export const ModelName = {
|
|||
AuthSecurity: 'AuthSecurity',
|
||||
Setting: 'Setting',
|
||||
StudyActivity: 'StudyActivity',
|
||||
ArcadePack: 'ArcadePack',
|
||||
ArcadeAttempt: 'ArcadeAttempt',
|
||||
MaterialGroup: 'MaterialGroup',
|
||||
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||
|
|
@ -430,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
|
||||
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
|
|
@ -1322,154 +1307,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||
}
|
||||
}
|
||||
}
|
||||
ArcadePack: {
|
||||
payload: Prisma.$ArcadePackPayload<ExtArgs>
|
||||
fields: Prisma.ArcadePackFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.ArcadePackFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.ArcadePackFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.ArcadePackFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.ArcadePackFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.ArcadePackFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.ArcadePackCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.ArcadePackCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.ArcadePackCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.ArcadePackDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.ArcadePackUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.ArcadePackDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.ArcadePackUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.ArcadePackUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.ArcadePackUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.ArcadePackAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadePack>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.ArcadePackGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadePackGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.ArcadePackCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadePackCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
ArcadeAttempt: {
|
||||
payload: Prisma.$ArcadeAttemptPayload<ExtArgs>
|
||||
fields: Prisma.ArcadeAttemptFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.ArcadeAttemptFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.ArcadeAttemptFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.ArcadeAttemptFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.ArcadeAttemptFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.ArcadeAttemptFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.ArcadeAttemptCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.ArcadeAttemptCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.ArcadeAttemptCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.ArcadeAttemptDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.ArcadeAttemptUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.ArcadeAttemptDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.ArcadeAttemptUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.ArcadeAttemptUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.ArcadeAttemptUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.ArcadeAttemptAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadeAttempt>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.ArcadeAttemptGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.ArcadeAttemptCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MaterialGroup: {
|
||||
payload: Prisma.$MaterialGroupPayload<ExtArgs>
|
||||
fields: Prisma.MaterialGroupFieldRefs
|
||||
|
|
@ -1945,43 +1782,6 @@ export const StudyActivityScalarFieldEnum = {
|
|||
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
|
||||
|
||||
|
||||
export const ArcadePackScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
gameType: 'gameType',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
schemaVersion: 'schemaVersion',
|
||||
sourceJson: 'sourceJson',
|
||||
normalizedJson: 'normalizedJson',
|
||||
validationReportJson: 'validationReportJson',
|
||||
sortOrder: 'sortOrder',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
|
||||
|
||||
|
||||
export const ArcadeAttemptScalarFieldEnum = {
|
||||
id: 'id',
|
||||
arcadePackId: 'arcadePackId',
|
||||
mode: 'mode',
|
||||
score: 'score',
|
||||
maxScore: 'maxScore',
|
||||
accuracy: 'accuracy',
|
||||
durationSeconds: 'durationSeconds',
|
||||
mistakes: 'mistakes',
|
||||
hintsUsed: 'hintsUsed',
|
||||
settingsJson: 'settingsJson',
|
||||
resultsJson: 'resultsJson',
|
||||
seed: 'seed',
|
||||
completedAt: 'completedAt'
|
||||
} as const
|
||||
|
||||
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
|
||||
|
||||
|
||||
export const MaterialGroupScalarFieldEnum = {
|
||||
id: 'id',
|
||||
classId: 'classId',
|
||||
|
|
@ -2106,10 +1906,19 @@ export type BatchPayload = {
|
|||
export const defineExtension = runtime.Extensions.defineExtension as unknown as runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs>
|
||||
export type DefaultPrismaClient = PrismaClient
|
||||
export type ErrorFormat = 'pretty' | 'colorless' | 'minimal'
|
||||
/**
|
||||
* Options common to all variants of `PrismaClientOptions`, regardless of whether you connect to your database through a driver adapter or through Prisma Accelerate.
|
||||
export type PrismaClientOptions = ({
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export class ArcadeImportError extends Error {
|
||||
constructor(message: string, public readonly details: string[] = []) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
14
src/lib/validation/activitySchemas.test.ts
Normal file
14
src/lib/validation/activitySchemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
98
tests/arcadeRemovalMigration.test.ts
Normal file
98
tests/arcadeRemovalMigration.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue