Initial addition of the Arcade study feature. Add connections as the first working game.
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s
This commit is contained in:
parent
7b90409f2e
commit
611a585757
43 changed files with 5990 additions and 68 deletions
40
prisma/migrations/20260714010000_add_arcade/migration.sql
Normal file
40
prisma/migrations/20260714010000_add_arcade/migration.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ArcadePack" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"gameType" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"schemaVersion" INTEGER NOT NULL,
|
||||
"sourceJson" TEXT NOT NULL,
|
||||
"normalizedJson" TEXT NOT NULL,
|
||||
"validationReportJson" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArcadeAttempt" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"arcadePackId" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL,
|
||||
"maxScore" INTEGER NOT NULL,
|
||||
"accuracy" REAL NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL,
|
||||
"mistakes" INTEGER NOT NULL,
|
||||
"hintsUsed" INTEGER NOT NULL,
|
||||
"settingsJson" TEXT NOT NULL,
|
||||
"resultsJson" TEXT NOT NULL,
|
||||
"seed" TEXT NOT NULL,
|
||||
"completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt");
|
||||
|
|
@ -18,6 +18,7 @@ model Class {
|
|||
quizSets QuizSet[]
|
||||
materialGroups MaterialGroup[]
|
||||
spacedRepetitionSets SpacedRepetitionSet[]
|
||||
arcadePacks ArcadePack[]
|
||||
}
|
||||
|
||||
model Deck {
|
||||
|
|
@ -146,12 +147,52 @@ model Setting {
|
|||
|
||||
model StudyActivity {
|
||||
id String @id @default(uuid())
|
||||
type String // "FLASHCARD" | "QUIZ_QUESTION"
|
||||
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
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") notFound();
|
||||
const mistakesValue = Number(query.mistakes);
|
||||
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
|
||||
? mistakesValue
|
||||
: pack.normalized.settings.allowedMistakes;
|
||||
const Renderer = ARCADE_RENDERERS.connections;
|
||||
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={pack.normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
|
||||
}
|
||||
12
src/app/(protected)/[classSlug]/arcade/connections/page.tsx
Normal file
12
src/app/(protected)/[classSlug]/arcade/connections/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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} />;
|
||||
}
|
||||
3
src/app/(protected)/[classSlug]/arcade/layout.tsx
Normal file
3
src/app/(protected)/[classSlug]/arcade/layout.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="arcade-route">{children}</div>;
|
||||
}
|
||||
46
src/app/(protected)/[classSlug]/arcade/page.tsx
Normal file
46
src/app/(protected)/[classSlug]/arcade/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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 Connections →" : "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>
|
||||
);
|
||||
}
|
||||
19
src/app/api/arcade/import/preview/route.ts
Normal file
19
src/app/api/arcade/import/preview/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
|
||||
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.rawJson));
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
35
src/app/api/arcade/packs/[id]/attempts/route.ts
Normal file
35
src/app/api/arcade/packs/[id]/attempts/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { arcadeAttemptCreateSchema } 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 parsed = arcadeAttemptCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const attempt = await arcadeService.createArcadeAttempt(id, parsed.data);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src/app/api/arcade/packs/[id]/route.ts
Normal file
38
src/app/api/arcade/packs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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 });
|
||||
}
|
||||
34
src/app/api/arcade/packs/route.ts
Normal file
34
src/app/api/arcade/packs/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as settingsService from "@/services/settingsService";
|
||||
|
||||
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
||||
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
||||
return type === "flashcards" || type === "quizzes" || type === "connections" ? type : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const type = getInstructionType(request);
|
||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||
const instructions = await settingsService.getLlmInstructions(type);
|
||||
return NextResponse.json({ value: instructions });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const type = getInstructionType(request);
|
||||
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.value !== "string") {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,334 @@
|
|||
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
||||
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
.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); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Manrope, Newsreader } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
|
|
@ -26,10 +25,12 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col font-sans antialiased">
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{`
|
||||
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
id="theme-init"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
const savedTheme = localStorage.theme
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
|
@ -39,8 +40,11 @@ export default function RootLayout({
|
|||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
} catch (_) {}
|
||||
`}
|
||||
</Script>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface DailyActivity {
|
|||
date: string;
|
||||
flashcards: number;
|
||||
questions: number;
|
||||
arcade: number;
|
||||
total: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
|
@ -152,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
|
|||
return (
|
||||
<div role="tooltip" className="rounded-lg border border-white/15 bg-[#0d1422] px-3 py-2 text-right text-xs shadow-xl">
|
||||
<div className="font-bold text-white">{formatted} · {day.total} total</div>
|
||||
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
|
||||
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -183,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
|
|||
}
|
||||
|
||||
function getDayAriaLabel(day: DailyActivity) {
|
||||
return `${day.date}: ${day.flashcards} flashcards and ${day.questions} questions, ${day.total} total activities`;
|
||||
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
|
||||
}
|
||||
|
|
|
|||
36
src/components/arcade/ArcadeGameShell.tsx
Normal file
36
src/components/arcade/ArcadeGameShell.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ArcadeGameShell({
|
||||
title,
|
||||
exitHref,
|
||||
elapsedSeconds,
|
||||
complete,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
exitHref: string;
|
||||
elapsedSeconds: number;
|
||||
complete: boolean;
|
||||
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="connections-world connections-stage mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3">
|
||||
<header className="connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
120
src/components/arcade/ArcadeImportModal.tsx
Normal file
120
src/components/arcade/ArcadeImportModal.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GenerateTab } from "@/components/import/GenerateTab";
|
||||
import type { ArcadeImportBatchPreview } from "@/types/arcade";
|
||||
|
||||
export function ArcadeImportModal({
|
||||
classId,
|
||||
onClose,
|
||||
onImported,
|
||||
}: {
|
||||
classId: string;
|
||||
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: "connections", 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: "connections",
|
||||
rawJson,
|
||||
names: names.map((name) => name.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="connections-world connections-import relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl">
|
||||
<div className="flex items-center justify-between px-6 pt-5">
|
||||
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import Connections pack</h2>
|
||||
<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="connections" /> : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Paste one pack object or an array of up to ten packs. Every board is validated before anything is saved.</p>
|
||||
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder="Paste Connections JSON object or array here…" className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
|
||||
{!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" />
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
237
src/components/arcade/ConnectionsGame.tsx
Normal file
237
src/components/arcade/ConnectionsGame.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"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>;
|
||||
}
|
||||
123
src/components/arcade/ConnectionsHub.tsx
Normal file
123
src/components/arcade/ConnectionsHub.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"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} onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/arcade/rendererRegistry.ts
Normal file
5
src/components/arcade/rendererRegistry.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
|
||||
|
||||
export const ARCADE_RENDERERS = {
|
||||
connections: ConnectionsGame,
|
||||
} as const;
|
||||
|
|
@ -2,12 +2,18 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" }) {
|
||||
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 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}`;
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
||||
|
|
@ -52,7 +58,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(instructions);
|
||||
await navigator.clipboard.writeText(displayedInstructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
|
@ -67,9 +73,23 @@ 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={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
value={displayedInstructions}
|
||||
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : 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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { STUDY_MODES } from "@/config/studyModes";
|
||||
|
||||
function Mark() {
|
||||
return (
|
||||
|
|
@ -78,36 +79,28 @@ export function Navbar() {
|
|||
{classSlug && (
|
||||
<div className="pt-6">
|
||||
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
|
||||
<Link
|
||||
href={`/${classSlug}/flashcards`}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/flashcards") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||
>
|
||||
<span aria-hidden>▤</span> Flashcards
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${classSlug}/quizzes`}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/quizzes") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||
>
|
||||
<span aria-hidden>✓</span> Quizzes
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${classSlug}/spaced-repetition`}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/spaced-repetition") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||
>
|
||||
<span aria-hidden>↻</span> Spaced Repetition
|
||||
{classSlug && hasReadyCards && (
|
||||
<span
|
||||
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
|
||||
title="Spaced repetition cards are ready to study"
|
||||
{STUDY_MODES.map((mode, index) => {
|
||||
const isActive = pathname.includes(`/${mode.path}`);
|
||||
return (
|
||||
<Link
|
||||
key={mode.key}
|
||||
href={`/${classSlug}/${mode.path}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`${index > 0 ? "mt-1 " : ""}flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${isActive ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
|
||||
>
|
||||
<span aria-hidden>!</span>
|
||||
<span className="sr-only">Cards ready to study</span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<span aria-hidden>{mode.icon}</span> {mode.label}
|
||||
{mode.key === "spaced-repetition" && hasReadyCards && (
|
||||
<span
|
||||
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
|
||||
title="Spaced repetition cards are ready to study"
|
||||
>
|
||||
<span aria-hidden>!</span>
|
||||
<span className="sr-only">Cards ready to study</span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
|
@ -126,11 +119,11 @@ export function Navbar() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
|
||||
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
|
||||
{nav}
|
||||
</aside>
|
||||
|
||||
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
|
||||
<header className="app-mobile-header sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<Mark />
|
||||
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
|
||||
|
|
|
|||
34
src/config/arcadeGames.ts
Normal file
34
src/config/arcadeGames.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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: false,
|
||||
estimatedMinutes: "Coming soon",
|
||||
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;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
export const STUDY_MODES = [
|
||||
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
|
||||
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
|
||||
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition" },
|
||||
{ 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,6 +77,16 @@ 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,6 +101,16 @@ 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
|
|
@ -396,6 +396,8 @@ export const ModelName = {
|
|||
AuthSecurity: 'AuthSecurity',
|
||||
Setting: 'Setting',
|
||||
StudyActivity: 'StudyActivity',
|
||||
ArcadePack: 'ArcadePack',
|
||||
ArcadeAttempt: 'ArcadeAttempt',
|
||||
MaterialGroup: 'MaterialGroup',
|
||||
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||
|
|
@ -415,7 +417,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" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
|
||||
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
|
|
@ -1307,6 +1309,154 @@ 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
|
||||
|
|
@ -1779,6 +1929,43 @@ 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',
|
||||
|
|
@ -2015,6 +2202,8 @@ 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,6 +63,8 @@ export const ModelName = {
|
|||
AuthSecurity: 'AuthSecurity',
|
||||
Setting: 'Setting',
|
||||
StudyActivity: 'StudyActivity',
|
||||
ArcadePack: 'ArcadePack',
|
||||
ArcadeAttempt: 'ArcadeAttempt',
|
||||
MaterialGroup: 'MaterialGroup',
|
||||
SpacedRepetitionSet: 'SpacedRepetitionSet',
|
||||
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
|
||||
|
|
@ -222,6 +224,43 @@ 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,6 +20,8 @@ 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'
|
||||
|
|
|
|||
1696
src/generated/prisma/models/ArcadeAttempt.ts
Normal file
1696
src/generated/prisma/models/ArcadeAttempt.ts
Normal file
File diff suppressed because it is too large
Load diff
1802
src/generated/prisma/models/ArcadePack.ts
Normal file
1802
src/generated/prisma/models/ArcadePack.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -220,6 +220,7 @@ export type ClassWhereInput = {
|
|||
quizSets?: Prisma.QuizSetListRelationFilter
|
||||
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
|
||||
arcadePacks?: Prisma.ArcadePackListRelationFilter
|
||||
}
|
||||
|
||||
export type ClassOrderByWithRelationInput = {
|
||||
|
|
@ -232,6 +233,7 @@ export type ClassOrderByWithRelationInput = {
|
|||
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
|
||||
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
|
||||
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
||||
|
|
@ -247,6 +249,7 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
|
|||
quizSets?: Prisma.QuizSetListRelationFilter
|
||||
materialGroups?: Prisma.MaterialGroupListRelationFilter
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
|
||||
arcadePacks?: Prisma.ArcadePackListRelationFilter
|
||||
}, "id" | "slug">
|
||||
|
||||
export type ClassOrderByWithAggregationInput = {
|
||||
|
|
@ -283,6 +286,7 @@ export type ClassCreateInput = {
|
|||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateInput = {
|
||||
|
|
@ -295,6 +299,7 @@ export type ClassUncheckedCreateInput = {
|
|||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUpdateInput = {
|
||||
|
|
@ -307,6 +312,7 @@ export type ClassUpdateInput = {
|
|||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateInput = {
|
||||
|
|
@ -319,6 +325,7 @@ export type ClassUncheckedUpdateInput = {
|
|||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateManyInput = {
|
||||
|
|
@ -426,6 +433,20 @@ 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
|
||||
|
|
@ -463,6 +484,7 @@ export type ClassCreateWithoutDecksInput = {
|
|||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutDecksInput = {
|
||||
|
|
@ -474,6 +496,7 @@ export type ClassUncheckedCreateWithoutDecksInput = {
|
|||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutDecksInput = {
|
||||
|
|
@ -501,6 +524,7 @@ export type ClassUpdateWithoutDecksInput = {
|
|||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutDecksInput = {
|
||||
|
|
@ -512,6 +536,7 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
|
|||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutQuizSetsInput = {
|
||||
|
|
@ -523,6 +548,7 @@ export type ClassCreateWithoutQuizSetsInput = {
|
|||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
||||
|
|
@ -534,6 +560,7 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
|
|||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutQuizSetsInput = {
|
||||
|
|
@ -561,6 +588,7 @@ export type ClassUpdateWithoutQuizSetsInput = {
|
|||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
|
||||
|
|
@ -572,6 +600,71 @@ 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 = {
|
||||
|
|
@ -583,6 +676,7 @@ export type ClassCreateWithoutMaterialGroupsInput = {
|
|||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
||||
|
|
@ -594,6 +688,7 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
|
|||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
|
||||
|
|
@ -621,6 +716,7 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
|
|||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
||||
|
|
@ -632,6 +728,7 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
|
|||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
|
||||
|
|
@ -643,6 +740,7 @@ export type ClassCreateWithoutSpacedRepetitionSetsInput = {
|
|||
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
|
||||
|
|
@ -654,6 +752,7 @@ export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
|
|||
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
|
||||
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
|
||||
}
|
||||
|
||||
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
|
||||
|
|
@ -681,6 +780,7 @@ export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
|
|||
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
|
||||
|
|
@ -692,6 +792,7 @@ export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
|
|||
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
|
||||
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
|
||||
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
|
||||
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -704,6 +805,7 @@ export type ClassCountOutputType = {
|
|||
quizSets: number
|
||||
materialGroups: number
|
||||
spacedRepetitionSets: number
|
||||
arcadePacks: number
|
||||
}
|
||||
|
||||
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
|
|
@ -711,6 +813,7 @@ export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.
|
|||
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
|
||||
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
|
||||
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
|
||||
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -751,6 +854,13 @@ 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
|
||||
|
|
@ -762,6 +872,7 @@ 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"]>
|
||||
|
||||
|
|
@ -795,6 +906,7 @@ 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> = {}
|
||||
|
|
@ -807,6 +919,7 @@ 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
|
||||
|
|
@ -1212,6 +1325,7 @@ 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.
|
||||
|
|
@ -1732,6 +1846,30 @@ 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
|
||||
*/
|
||||
|
|
|
|||
63
src/lib/arcade/connectionsEngine.test.ts
Normal file
63
src/lib/arcade/connectionsEngine.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
110
src/lib/arcade/connectionsEngine.ts
Normal file
110
src/lib/arcade/connectionsEngine.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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 };
|
||||
}
|
||||
74
src/lib/arcade/connectionsImport.test.ts
Normal file
74
src/lib/arcade/connectionsImport.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
170
src/lib/arcade/connectionsImport.ts
Normal file
170
src/lib/arcade/connectionsImport.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { parseAndRepairJson } from "@/lib/jsonRepair";
|
||||
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeImportPreview,
|
||||
ArcadeImportBatchPreview,
|
||||
ArcadeValidationReport,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export class ArcadeImportError extends Error {
|
||||
constructor(message: string, public readonly details: string[] = []) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
35
src/lib/arcade/registry.ts
Normal file
35
src/lib/arcade/registry.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
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]`;
|
||||
|
||||
export const ARCADE_SERVER_REGISTRY = {
|
||||
connections: {
|
||||
schemaVersion: 1,
|
||||
preview: parseConnectionsImportBatch,
|
||||
defaultInstructions: CONNECTIONS_PROMPT,
|
||||
},
|
||||
} satisfies Record<ArcadeGameKey, {
|
||||
schemaVersion: number;
|
||||
preview: typeof parseConnectionsImportBatch;
|
||||
defaultInstructions: string;
|
||||
}>;
|
||||
|
||||
export function getArcadeGameDefinition(gameType: ArcadeGameKey) {
|
||||
return ARCADE_SERVER_REGISTRY[gameType];
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const studyActivitySchema = z.object({
|
||||
type: z.enum(["FLASHCARD", "QUIZ_QUESTION"]),
|
||||
type: z.enum(["FLASHCARD", "QUIZ_QUESTION", "ARCADE_GROUP"]),
|
||||
});
|
||||
|
||||
export type StudyActivityType = z.infer<typeof studyActivitySchema>["type"];
|
||||
|
|
|
|||
61
src/lib/validation/arcadeSchemas.ts
Normal file
61
src/lib/validation/arcadeSchemas.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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();
|
||||
|
||||
export const arcadePreviewRequestSchema = z.object({
|
||||
gameType: z.literal("connections"),
|
||||
rawJson: z.string().min(1),
|
||||
});
|
||||
|
||||
export const arcadePackCreateSchema = arcadePreviewRequestSchema.extend({
|
||||
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),
|
||||
});
|
||||
|
||||
export type ConnectionsImportInput = z.infer<typeof connectionsImportSchema>;
|
||||
export type ArcadeAttemptCreateInput = z.infer<typeof arcadeAttemptCreateSchema>;
|
||||
|
|
@ -10,6 +10,7 @@ export interface DailyActivity {
|
|||
date: string;
|
||||
flashcards: number;
|
||||
questions: number;
|
||||
arcade: number;
|
||||
total: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
|
@ -20,6 +21,7 @@ export interface ActivitySummary {
|
|||
activeDays: number;
|
||||
flashcards: number;
|
||||
questions: number;
|
||||
arcade: number;
|
||||
currentStreak: number;
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ function getLevel(total: number): 0 | 1 | 2 | 3 | 4 {
|
|||
}
|
||||
|
||||
function emptyDay(date: string): DailyActivity {
|
||||
return { date, flashcards: 0, questions: 0, total: 0, level: 0 };
|
||||
return { date, flashcards: 0, questions: 0, arcade: 0, total: 0, level: 0 };
|
||||
}
|
||||
|
||||
export async function recordActivity(type: StudyActivityType) {
|
||||
|
|
@ -71,6 +73,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
|
|||
const day = totals.get(date) ?? emptyDay(date);
|
||||
if (activity.type === "FLASHCARD") day.flashcards += 1;
|
||||
if (activity.type === "QUIZ_QUESTION") day.questions += 1;
|
||||
if (activity.type === "ARCADE_GROUP") day.arcade += 1;
|
||||
day.total += 1;
|
||||
day.level = getLevel(day.total);
|
||||
totals.set(date, day);
|
||||
|
|
@ -83,6 +86,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
|
|||
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
|
||||
|
|
@ -99,6 +103,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
|
|||
activeDays: visibleDays.filter((day) => day.total > 0).length,
|
||||
flashcards,
|
||||
questions,
|
||||
arcade,
|
||||
currentStreak,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
176
src/services/arcadeService.ts
Normal file
176
src/services/arcadeService.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
import { replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
|
||||
import type { ArcadeAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeAttemptSummary,
|
||||
ArcadeGameKey,
|
||||
ArcadePackSummary,
|
||||
NormalizedConnectionsPack,
|
||||
} 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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function previewArcadeImport(rawJson: string) {
|
||||
return parseConnectionsImportBatch(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 = parseConnectionsImportBatch(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.normalized),
|
||||
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 NormalizedConnectionsPack;
|
||||
return {
|
||||
id: pack.id,
|
||||
classId: pack.classId,
|
||||
gameType: pack.gameType as ArcadeGameKey,
|
||||
name: pack.name,
|
||||
description: pack.description,
|
||||
schemaVersion: pack.schemaVersion,
|
||||
itemCount: normalized.content.length * 4,
|
||||
defaultAllowedMistakes: normalized.settings.allowedMistakes,
|
||||
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 NormalizedConnectionsPack,
|
||||
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;
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
import { ARCADE_SERVER_REGISTRY } from "@/lib/arcade/registry";
|
||||
|
||||
export type LlmInstructionType = "flashcards" | "quizzes" | "connections";
|
||||
|
||||
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
|
||||
|
|
@ -48,9 +51,14 @@ Rules:
|
|||
Material:
|
||||
[paste your notes or lecture content here]`;
|
||||
|
||||
export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promise<string> {
|
||||
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
|
||||
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
|
||||
function instructionConfig(type: LlmInstructionType) {
|
||||
if (type === "flashcards") return { key: "llmInstructionsFlashcards", value: DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS };
|
||||
if (type === "quizzes") return { key: "llmInstructionsQuizzes", value: DEFAULT_LLM_INSTRUCTIONS_QUIZZES };
|
||||
return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
|
||||
}
|
||||
|
||||
export async function getLlmInstructions(type: LlmInstructionType): Promise<string> {
|
||||
const { key, value: def } = instructionConfig(type);
|
||||
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key },
|
||||
|
|
@ -58,8 +66,8 @@ export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promis
|
|||
return setting?.value ?? def;
|
||||
}
|
||||
|
||||
export async function updateLlmInstructions(type: "flashcards" | "quizzes", value: string) {
|
||||
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
|
||||
export async function updateLlmInstructions(type: LlmInstructionType, value: string) {
|
||||
const { key } = instructionConfig(type);
|
||||
|
||||
return prisma.setting.upsert({
|
||||
where: { key },
|
||||
|
|
@ -68,9 +76,8 @@ export async function updateLlmInstructions(type: "flashcards" | "quizzes", valu
|
|||
});
|
||||
}
|
||||
|
||||
export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
|
||||
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
|
||||
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
|
||||
export async function resetLlmInstructions(type: LlmInstructionType) {
|
||||
const { key, value: def } = instructionConfig(type);
|
||||
|
||||
return prisma.setting.upsert({
|
||||
where: { key },
|
||||
|
|
@ -78,4 +85,3 @@ export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
|
|||
create: { key, value: def },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/types/arcade.ts
Normal file
123
src/types/arcade.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
export type ArcadeGameKey = "connections";
|
||||
|
||||
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 ArcadeValidationReport {
|
||||
wasRepaired: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ArcadeImportPreview {
|
||||
gameType: ArcadeGameKey;
|
||||
name: string;
|
||||
description?: string;
|
||||
categories: string[];
|
||||
itemCount: number;
|
||||
wasRepaired: boolean;
|
||||
warnings: string[];
|
||||
normalized: NormalizedConnectionsPack;
|
||||
}
|
||||
|
||||
export interface ArcadeImportBatchPreview {
|
||||
packs: ArcadeImportPreview[];
|
||||
count: number;
|
||||
wasRepaired: boolean;
|
||||
}
|
||||
|
||||
export interface ArcadeSessionSettings {
|
||||
allowedMistakes: number;
|
||||
oneAwayFeedback: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ArcadePackSummary {
|
||||
id: string;
|
||||
classId: string;
|
||||
gameType: ArcadeGameKey;
|
||||
name: string;
|
||||
description: string | null;
|
||||
schemaVersion: number;
|
||||
itemCount: number;
|
||||
defaultAllowedMistakes: number;
|
||||
bestScore: number | null;
|
||||
latestAttempt: ArcadeAttemptSummary | null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue