Initial crossword addition and arcade redesign
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s
This commit is contained in:
parent
611a585757
commit
5bec95fb30
32 changed files with 1635 additions and 56 deletions
|
|
@ -8,11 +8,12 @@ export default async function ConnectionsPlayPage(
|
|||
) {
|
||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
||||
const pack = await getArcadePack(packId);
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections") notFound();
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound();
|
||||
const normalized = pack.normalized;
|
||||
const mistakesValue = Number(query.mistakes);
|
||||
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
|
||||
? mistakesValue
|
||||
: pack.normalized.settings.allowedMistakes;
|
||||
: normalized.settings.allowedMistakes;
|
||||
const Renderer = ARCADE_RENDERERS.connections;
|
||||
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={pack.normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
|
||||
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
const SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
|
||||
|
||||
export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
|
||||
const pack = await getArcadePack(packId);
|
||||
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound();
|
||||
const requestedSize = typeof query.size === "string" ? query.size : "standard";
|
||||
const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard";
|
||||
const seed = randomUUID();
|
||||
const normalized = pack.normalized as NormalizedCrosswordPack;
|
||||
const layout = generateCrosswordLayout(normalized, size, seed);
|
||||
const Renderer = ARCADE_RENDERERS.crossword;
|
||||
return <Renderer seed={seed} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={normalized} layout={layout} settings={{ size, instantCheck: query.instant === "1", allowHints: query.hints !== "0" }} />;
|
||||
}
|
||||
12
src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
Normal file
12
src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { CrosswordHub } from "@/components/arcade/CrosswordHub";
|
||||
import { getClassBySlug } from "@/services/classService";
|
||||
import { listArcadePacks } from "@/services/arcadeService";
|
||||
|
||||
export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) {
|
||||
const { classSlug } = await props.params;
|
||||
const classItem = await getClassBySlug(classSlug);
|
||||
if (!classItem) notFound();
|
||||
const packs = await listArcadePacks(classItem.id, "crossword");
|
||||
return <CrosswordHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">
|
|||
|
||||
<div className="arcade-cabinet-grid grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{ARCADE_GAMES.map((game) => {
|
||||
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? "Play Connections →" : "Coming soon"}</span></div></article>;
|
||||
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? `Play ${game.name} →` : "Coming soon"}</span></div></article>;
|
||||
return game.available ? <Link key={game.key} href={`/${classSlug}/arcade/${game.path}`} className="rounded-[1.75rem] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">{card}</Link> : <div key={game.key}>{card}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.rawJson));
|
||||
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson));
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { arcadeAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
export async function GET(
|
||||
|
|
@ -17,12 +17,19 @@ export async function POST(
|
|||
context: RouteContext<"/api/arcade/packs/[id]/attempts">
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const parsed = arcadeAttemptCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
const pack = await arcadeService.getArcadePack(id);
|
||||
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = pack.gameType === "crossword"
|
||||
? crosswordAttemptCreateSchema.safeParse(body)
|
||||
: arcadeAttemptCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const attempt = await arcadeService.createArcadeAttempt(id, parsed.data);
|
||||
const attempt = pack.gameType === "crossword"
|
||||
? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters<typeof arcadeService.createCrosswordAttempt>[1])
|
||||
: await arcadeService.createArcadeAttempt(id, parsed.data as Parameters<typeof arcadeService.createArcadeAttempt>[1]);
|
||||
return attempt
|
||||
? NextResponse.json(attempt, { status: 201 })
|
||||
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
|
||||
|
|
|
|||
23
src/app/api/arcade/packs/[id]/layout/route.ts
Normal file
23
src/app/api/arcade/packs/[id]/layout/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type { CrosswordSize } from "@/types/arcade";
|
||||
import { getArcadePack } from "@/services/arcadeService";
|
||||
|
||||
const CROSSWORD_SIZES = new Set<CrosswordSize>(["mini", "standard", "large", "extra-large"]);
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard";
|
||||
if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) {
|
||||
return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 });
|
||||
}
|
||||
const pack = await getArcadePack(id);
|
||||
if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") {
|
||||
return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 });
|
||||
}
|
||||
const size = sizeValue as CrosswordSize;
|
||||
return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`));
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import * as arcadeService from "@/services/arcadeService";
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ export async function GET(request: NextRequest) {
|
|||
const params = new URL(request.url).searchParams;
|
||||
const classId = params.get("classId");
|
||||
const gameType = params.get("gameType");
|
||||
if (!classId || gameType !== "connections") {
|
||||
if (!classId || (gameType !== "connections" && gameType !== "crossword")) {
|
||||
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as settingsService from "@/services/settingsService";
|
|||
|
||||
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
|
||||
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
|
||||
return type === "flashcards" || type === "quizzes" || type === "connections" ? type : null;
|
||||
return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
|
|||
|
|
@ -498,6 +498,206 @@ body:has(.connections-world) {
|
|||
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
|
||||
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
|
||||
|
||||
/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */
|
||||
body:has(.crossword-world) {
|
||||
--theme-bg-base: #20170f;
|
||||
--theme-bg-surface: #f6ecd2;
|
||||
--theme-bg-surface-alt: #ead9b5;
|
||||
--theme-bg-callout: #dfc795;
|
||||
--theme-primary: #9a5a24;
|
||||
--theme-primary-hover: #7b431b;
|
||||
--theme-text-heading: #2d2117;
|
||||
--theme-text-body: #493725;
|
||||
--theme-text-secondary: #68523a;
|
||||
--theme-text-muted: #8a7052;
|
||||
--theme-border: #a9865d;
|
||||
--theme-border-light: #cfb88e;
|
||||
background:
|
||||
radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem),
|
||||
linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e);
|
||||
}
|
||||
body:has(.crossword-world)::before {
|
||||
opacity: .16;
|
||||
background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px);
|
||||
}
|
||||
body:has(.crossword-world) .app-sidebar,
|
||||
body:has(.crossword-world) .app-mobile-header {
|
||||
--theme-bg-surface-alt: #3a2819;
|
||||
--theme-bg-callout: #e4cc98;
|
||||
--theme-primary: #8a4f20;
|
||||
--theme-text-heading: #fff3d8;
|
||||
--theme-text-secondary: #d7c3a2;
|
||||
--theme-text-muted: #aa9170;
|
||||
--theme-border-light: #5b4028;
|
||||
color: #f8ead0;
|
||||
border-color: rgba(224, 190, 126, .22);
|
||||
background: rgba(35, 23, 14, .94);
|
||||
}
|
||||
|
||||
.crossword-world {
|
||||
position: relative;
|
||||
color: var(--theme-text-body);
|
||||
}
|
||||
.crossword-hub.crossword-world,
|
||||
.crossword-stage.crossword-world { overflow: visible; }
|
||||
.crossword-hero,
|
||||
.crossword-setup,
|
||||
.crossword-active-clue,
|
||||
.crossword-clues,
|
||||
.crossword-import,
|
||||
.crossword-paper {
|
||||
border: 1px solid rgba(100, 68, 35, .24);
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)),
|
||||
repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px),
|
||||
#f4e7c9;
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25);
|
||||
}
|
||||
.crossword-hero { position: relative; overflow: hidden; }
|
||||
.crossword-hero > * { position: relative; z-index: 1; }
|
||||
.crossword-hero::after {
|
||||
content: "DAILY STUDY";
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: .35rem;
|
||||
color: rgba(77,51,28,.07);
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(2.75rem, 5vw, 5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -.06em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.crossword-kicker,
|
||||
.crossword-section-label {
|
||||
color: #925623;
|
||||
font-size: .68rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); }
|
||||
.crossword-primary {
|
||||
color: #fff8e7;
|
||||
background: linear-gradient(135deg, #a86429, #744018);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22);
|
||||
transition: transform .16s var(--ease-spring), filter .16s ease;
|
||||
}
|
||||
.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
||||
.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; }
|
||||
.crossword-pack {
|
||||
border-color: rgba(111,76,43,.25);
|
||||
background: rgba(244,231,201,.92);
|
||||
box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65);
|
||||
transition: transform .18s ease, border-color .18s ease;
|
||||
}
|
||||
.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; }
|
||||
.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); }
|
||||
.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); }
|
||||
.crossword-size {
|
||||
color: var(--theme-text-secondary);
|
||||
border: 1px solid var(--theme-border-light);
|
||||
background: rgba(255,250,235,.48);
|
||||
}
|
||||
.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
|
||||
.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); }
|
||||
.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; }
|
||||
.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.crossword-preview-heading small { color: #8a7052; font-size: .68rem; }
|
||||
.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; }
|
||||
.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); }
|
||||
.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; }
|
||||
.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; }
|
||||
.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); }
|
||||
.crossword-option input { margin-top: .2rem; accent-color: #925623; }
|
||||
.crossword-option strong,.crossword-option small { display: block; }
|
||||
.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); }
|
||||
.crossword-active-clue { display: grid; gap: .2rem; }
|
||||
.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
|
||||
.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; }
|
||||
.crossword-active-clue small { color: var(--theme-text-muted); }
|
||||
.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); }
|
||||
.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; }
|
||||
.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; }
|
||||
.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; }
|
||||
.crossword-board-viewport {
|
||||
max-height: min(68vh, 46rem);
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
overscroll-behavior: contain;
|
||||
background: #2a1d13;
|
||||
box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3);
|
||||
}
|
||||
.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; }
|
||||
.crossword-cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: var(--crossword-cell);
|
||||
height: var(--crossword-cell);
|
||||
place-items: center;
|
||||
color: #25190f;
|
||||
border: 1px solid #6f5335;
|
||||
background: #fff8e6;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
transition: background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; }
|
||||
.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); }
|
||||
.crossword-cell.is-word { background: #f2d99f; }
|
||||
.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; }
|
||||
.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; }
|
||||
.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; }
|
||||
.crossword-secondary {
|
||||
min-height: 2.75rem;
|
||||
padding: .6rem .9rem;
|
||||
border: 1px solid #9f7a50;
|
||||
border-radius: .7rem;
|
||||
color: #392718;
|
||||
background: #ead6ae;
|
||||
font-size: .78rem;
|
||||
font-weight: 850;
|
||||
box-shadow: 0 3px 0 #aa895f;
|
||||
}
|
||||
.crossword-secondary:hover { background: #f5e5c4; }
|
||||
.crossword-clues { max-height: 72vh; overflow: hidden; }
|
||||
.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; }
|
||||
.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; }
|
||||
.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; }
|
||||
.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; }
|
||||
.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; }
|
||||
.crossword-clues li b { color: #8a4f20; }
|
||||
.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); }
|
||||
.crossword-results-hero > div > div { background: rgba(255,244,220,.1); }
|
||||
.crossword-results-hero small,.crossword-results-hero strong { display: block; }
|
||||
.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; }
|
||||
.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; }
|
||||
.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); }
|
||||
.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; }
|
||||
.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; }
|
||||
.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; }
|
||||
.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; }
|
||||
.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); }
|
||||
.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; }
|
||||
.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); }
|
||||
.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); }
|
||||
.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; }
|
||||
.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); }
|
||||
.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); }
|
||||
.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; }
|
||||
.crossword-review.is-correct { border-left: 6px solid #4f7a4f; }
|
||||
.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; }
|
||||
.crossword-board { place-content: start; min-height: 24rem; }
|
||||
.crossword-clues { max-height: 28rem; }
|
||||
.crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; }
|
||||
.crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; }
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ export function ArcadeGameShell({
|
|||
exitHref,
|
||||
elapsedSeconds,
|
||||
complete,
|
||||
worldClassName = "connections-world connections-stage",
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
exitHref: string;
|
||||
elapsedSeconds: number;
|
||||
complete: boolean;
|
||||
worldClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
|
@ -24,8 +26,8 @@ export function ArcadeGameShell({
|
|||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
|
||||
return (
|
||||
<div className="connections-world connections-stage mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3">
|
||||
<header className="connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
|
||||
<div className={`${worldClassName} mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3`}>
|
||||
<header className="arcade-game-topbar connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
|
||||
<button onClick={exitGame} className="min-h-10 rounded-xl px-3 text-sm font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">← Exit</button>
|
||||
<h1 className="truncate text-center text-lg font-extrabold text-text-heading">{title}</h1>
|
||||
<div className="min-w-16 text-right font-mono text-sm font-bold text-text-muted" aria-label={`${elapsedSeconds} seconds elapsed`}>{minutes}:{seconds}</div>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { GenerateTab } from "@/components/import/GenerateTab";
|
||||
import type { ArcadeImportBatchPreview } from "@/types/arcade";
|
||||
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
|
||||
|
||||
export function ArcadeImportModal({
|
||||
classId,
|
||||
gameType,
|
||||
onClose,
|
||||
onImported,
|
||||
}: {
|
||||
classId: string;
|
||||
gameType: ArcadeGameKey;
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}) {
|
||||
|
|
@ -31,7 +33,7 @@ export function ArcadeImportModal({
|
|||
const response = await fetch("/api/arcade/import/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType: "connections", rawJson }),
|
||||
body: JSON.stringify({ gameType, rawJson }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
|
|
@ -56,9 +58,9 @@ export function ArcadeImportModal({
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
gameType: "connections",
|
||||
gameType,
|
||||
rawJson,
|
||||
names: names.map((name) => name.trim()),
|
||||
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
|
||||
warningsAcknowledged: acknowledged,
|
||||
}),
|
||||
});
|
||||
|
|
@ -77,9 +79,9 @@ export function ArcadeImportModal({
|
|||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
|
||||
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className="connections-world connections-import relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl">
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className={`${gameType === "connections" ? "connections-world connections-import" : "crossword-world crossword-import"} relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl`}>
|
||||
<div className="flex items-center justify-between px-6 pt-5">
|
||||
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import Connections pack</h2>
|
||||
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import {gameType === "connections" ? "Connections" : "Crossword"} pack</h2>
|
||||
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
|
||||
|
|
@ -88,10 +90,10 @@ export function ArcadeImportModal({
|
|||
))}
|
||||
</div>
|
||||
<div className="overflow-y-auto p-6">
|
||||
{tab === "generate" ? <GenerateTab importType="connections" /> : (
|
||||
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Paste one pack object or an array of up to ten packs. Every board is validated before anything is saved.</p>
|
||||
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder="Paste Connections JSON object or array here…" className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
|
||||
<p className="text-sm text-text-secondary">{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.</p>
|
||||
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder={`Paste ${gameType === "connections" ? "Connections" : "Crossword"} JSON here…`} className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
|
||||
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
|
||||
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
|
||||
{preview && (
|
||||
|
|
@ -102,8 +104,13 @@ export function ArcadeImportModal({
|
|||
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
|
||||
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
|
||||
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
|
||||
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
|
||||
{gameType === "connections" ? <>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
|
||||
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
|
||||
</> : <>
|
||||
<p className="mt-2 text-xs text-text-muted">80 entries · four board sizes</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-text-secondary">{pack.layoutPreviews?.map((layout) => <span key={layout.size} className="rounded-lg bg-bg-surface-alt px-2 py-1.5 capitalize">{layout.size}: {layout.placedCount}/{layout.targetCount}</span>)}</div>
|
||||
</>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export function ConnectionsHub({
|
|||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
{showImport && <ArcadeImportModal classId={classId} gameType="connections" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
264
src/components/arcade/CrosswordGame.tsx
Normal file
264
src/components/arcade/CrosswordGame.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
|
||||
import type {
|
||||
CrosswordAction,
|
||||
CrosswordLayout,
|
||||
CrosswordPlacedEntry,
|
||||
CrosswordRoundResult,
|
||||
CrosswordSessionSettings,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function entryValue(entry: CrosswordPlacedEntry, answers: Record<string, string>) {
|
||||
return entry.cellKeys.map((cellKey) => answers[cellKey] ?? "").join("");
|
||||
}
|
||||
|
||||
export function CrosswordGame({ seed, packId, packName, classSlug, layout, settings }: {
|
||||
seed: string;
|
||||
packId: string;
|
||||
packName: string;
|
||||
classSlug: string;
|
||||
pack: NormalizedCrosswordPack;
|
||||
layout: CrosswordLayout;
|
||||
settings: CrosswordSessionSettings;
|
||||
}) {
|
||||
const orderedEntries = useMemo(() => [...layout.entries].sort((a, b) => a.number - b.number || a.direction.localeCompare(b.direction)), [layout.entries]);
|
||||
const entryById = useMemo(() => new Map(orderedEntries.map((entry) => [entry.id, entry])), [orderedEntries]);
|
||||
const cellByKey = useMemo(() => new Map(layout.cells.map((cell) => [cell.key, cell])), [layout.cells]);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [activeEntryId, setActiveEntryId] = useState(orderedEntries[0]?.id ?? "");
|
||||
const [activeCellKey, setActiveCellKey] = useState(orderedEntries[0]?.cellKeys[0] ?? "");
|
||||
const [clueTab, setClueTab] = useState<"across" | "down">(orderedEntries[0]?.direction ?? "across");
|
||||
const [actions, setActions] = useState<CrosswordAction[]>([]);
|
||||
const [checkedWrong, setCheckedWrong] = useState<Set<string>>(new Set());
|
||||
const [revealedCells, setRevealedCells] = useState<Set<string>>(new Set());
|
||||
const [alternateClues, setAlternateClues] = useState<Set<string>>(new Set());
|
||||
const [feedback, setFeedback] = useState(`${layout.entries.length} of ${layout.targetCount} target words placed.`);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [result, setResult] = useState<CrosswordRoundResult | null>(null);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const startedAt = useRef(0);
|
||||
const mobileInput = useRef<HTMLInputElement>(null);
|
||||
const activeEntry = entryById.get(activeEntryId) ?? orderedEntries[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt.current === 0) startedAt.current = Date.now();
|
||||
if (result) return;
|
||||
const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [result]);
|
||||
|
||||
function selectEntry(entry: CrosswordPlacedEntry, cellKey = entry.cellKeys[0]) {
|
||||
setActiveEntryId(entry.id);
|
||||
setActiveCellKey(cellKey);
|
||||
setClueTab(entry.direction);
|
||||
}
|
||||
|
||||
function selectCell(cellKey: string) {
|
||||
const cell = cellByKey.get(cellKey);
|
||||
if (!cell) return;
|
||||
let nextId = cell.entryIds[0];
|
||||
if (cell.entryIds.includes(activeEntryId) && cell.entryIds.length > 1 && activeCellKey === cellKey) {
|
||||
nextId = cell.entryIds.find((id) => id !== activeEntryId) ?? nextId;
|
||||
} else if (!cell.entryIds.includes(activeEntryId)) {
|
||||
nextId = cell.entryIds.find((id) => entryById.get(id)?.direction === clueTab) ?? nextId;
|
||||
} else {
|
||||
nextId = activeEntryId;
|
||||
}
|
||||
const entry = entryById.get(nextId);
|
||||
if (entry) selectEntry(entry, cellKey);
|
||||
mobileInput.current?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function moveWithinEntry(offset: number) {
|
||||
if (!activeEntry) return;
|
||||
const index = Math.max(0, activeEntry.cellKeys.indexOf(activeCellKey));
|
||||
setActiveCellKey(activeEntry.cellKeys[Math.max(0, Math.min(activeEntry.cellKeys.length - 1, index + offset))]);
|
||||
}
|
||||
|
||||
function writeLetter(letter: string) {
|
||||
if (!activeEntry || !activeCellKey || result || revealedCells.has(activeCellKey)) return;
|
||||
const upper = letter.replace(/[^A-Za-z]/g, "").slice(-1).toUpperCase();
|
||||
if (!upper) return;
|
||||
const nextAnswers = { ...answers, [activeCellKey]: upper };
|
||||
setAnswers(nextAnswers);
|
||||
if (settings.instantCheck) {
|
||||
const value = activeEntry.cellKeys.map((cellKey) => nextAnswers[cellKey] ?? "").join("");
|
||||
if (value.length === activeEntry.answer.length) {
|
||||
setCheckedWrong((checked) => {
|
||||
const updated = new Set(checked);
|
||||
activeEntry.cellKeys.forEach((cellKey, index) => {
|
||||
if ((nextAnswers[cellKey] ?? "") === activeEntry.answer[index]) updated.delete(cellKey);
|
||||
else updated.add(cellKey);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
moveWithinEntry(1);
|
||||
}
|
||||
|
||||
function clearLetter() {
|
||||
if (!activeCellKey || revealedCells.has(activeCellKey)) return;
|
||||
if (answers[activeCellKey]) {
|
||||
setAnswers((current) => { const next = { ...current }; delete next[activeCellKey]; return next; });
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
} else {
|
||||
moveWithinEntry(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleEntry(offset: number) {
|
||||
const index = Math.max(0, orderedEntries.findIndex((entry) => entry.id === activeEntryId));
|
||||
const entry = orderedEntries[(index + offset + orderedEntries.length) % orderedEntries.length];
|
||||
selectEntry(entry);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
if (/^[a-zA-Z]$/.test(event.key)) { event.preventDefault(); writeLetter(event.key); return; }
|
||||
if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); clearLetter(); return; }
|
||||
if (event.key === "Tab") { event.preventDefault(); cycleEntry(event.shiftKey ? -1 : 1); return; }
|
||||
if (event.key === "Enter") { event.preventDefault(); checkWord(); return; }
|
||||
const cell = cellByKey.get(activeCellKey);
|
||||
if (!cell || !event.key.startsWith("Arrow")) return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowLeft" ? [0, -1] : event.key === "ArrowRight" ? [0, 1] : event.key === "ArrowUp" ? [-1, 0] : [1, 0];
|
||||
const next = cellByKey.get(`${cell.row + delta[0]}:${cell.column + delta[1]}`);
|
||||
if (next) selectCell(next.key);
|
||||
}
|
||||
|
||||
function incorrectCells(entry: CrosswordPlacedEntry) {
|
||||
return entry.cellKeys.filter((cellKey, index) => (answers[cellKey] ?? "") !== entry.answer[index]);
|
||||
}
|
||||
|
||||
function checkWord() {
|
||||
if (!activeEntry) return;
|
||||
const wrong = incorrectCells(activeEntry);
|
||||
setCheckedWrong((current) => new Set([...current, ...wrong]));
|
||||
setActions((current) => [...current, { type: "CHECK_WORD", entryId: activeEntry.id, value: entryValue(activeEntry, answers), elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(wrong.length === 0 ? `${activeEntry.number} ${activeEntry.direction} is correct.` : `${wrong.length} cell${wrong.length === 1 ? " is" : "s are"} incorrect in this word.`);
|
||||
}
|
||||
|
||||
function checkPuzzle() {
|
||||
const wrong = orderedEntries.flatMap(incorrectCells);
|
||||
setCheckedWrong(new Set(wrong));
|
||||
setActions((current) => [...current, { type: "CHECK_PUZZLE", answers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])), elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(wrong.length === 0 ? "Every filled answer is correct." : `${wrong.length} cells need another look.`);
|
||||
}
|
||||
|
||||
function showAlternateClue() {
|
||||
if (!activeEntry || !settings.allowHints) return;
|
||||
setAlternateClues((current) => new Set(current).add(activeEntry.id));
|
||||
setActions((current) => [...current, { type: "ALTERNATE_CLUE", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
|
||||
}
|
||||
|
||||
function revealLetter() {
|
||||
if (!activeEntry || !activeCellKey || !settings.allowHints) return;
|
||||
const index = activeEntry.cellKeys.indexOf(activeCellKey);
|
||||
setAnswers((current) => ({ ...current, [activeCellKey]: activeEntry.answer[index] }));
|
||||
setRevealedCells((current) => new Set(current).add(activeCellKey));
|
||||
setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
|
||||
setActions((current) => [...current, { type: "REVEAL_LETTER", cellKey: activeCellKey, elapsedMs: Date.now() - startedAt.current }]);
|
||||
moveWithinEntry(1);
|
||||
}
|
||||
|
||||
function revealWord() {
|
||||
if (!activeEntry || !settings.allowHints) return;
|
||||
setAnswers((current) => ({ ...current, ...Object.fromEntries(activeEntry.cellKeys.map((cellKey, index) => [cellKey, activeEntry.answer[index]])) }));
|
||||
setRevealedCells((current) => new Set([...current, ...activeEntry.cellKeys]));
|
||||
setCheckedWrong((current) => { const next = new Set(current); activeEntry.cellKeys.forEach((cellKey) => next.delete(cellKey)); return next; });
|
||||
setActions((current) => [...current, { type: "REVEAL_WORD", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
|
||||
setFeedback(`${activeEntry.number} ${activeEntry.direction} was revealed.`);
|
||||
}
|
||||
|
||||
async function finish(gaveUp: boolean) {
|
||||
if (saveState === "saving") return;
|
||||
if (!gaveUp && !window.confirm("Submit this puzzle and reveal the results?")) return;
|
||||
if (gaveUp && !window.confirm("Give up and reveal every remaining answer?")) return;
|
||||
setSaveState("saving");
|
||||
const durationSeconds = Math.floor((Date.now() - startedAt.current) / 1000);
|
||||
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "CROSSWORD",
|
||||
durationSeconds,
|
||||
seed,
|
||||
settings,
|
||||
finalAnswers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])),
|
||||
actions,
|
||||
gaveUp,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.results) { setSaveState("error"); setFeedback(data.error ?? "Attempt could not be saved."); return; }
|
||||
setElapsedSeconds(durationSeconds);
|
||||
setResult(data.results);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return <ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete worldClassName="crossword-world crossword-stage"><CrosswordResults result={result} layout={layout} answers={answers} exitHref={`/${classSlug}/arcade/crossword`} /></ArcadeGameShell>;
|
||||
}
|
||||
|
||||
const activeKeys = new Set(activeEntry?.cellKeys ?? []);
|
||||
const visibleClues = orderedEntries.filter((entry) => entry.direction === clueTab);
|
||||
const cellSize = Math.round(34 * zoom);
|
||||
return (
|
||||
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/crossword`} elapsedSeconds={elapsedSeconds} complete={false} worldClassName="crossword-world crossword-stage">
|
||||
<input ref={mobileInput} value="" onKeyDown={handleKeyDown} onChange={(event) => writeLetter(event.target.value)} className="crossword-mobile-input" aria-label="Type crossword letter" autoCapitalize="characters" inputMode="text" />
|
||||
<section className="crossword-active-clue mb-4 rounded-2xl p-4" aria-live="polite"><span>{activeEntry?.number} {activeEntry?.direction}</span><strong>{alternateClues.has(activeEntry?.id ?? "") ? activeEntry?.alternateClue : activeEntry?.clue}</strong><small>{feedback}</small></section>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div>
|
||||
<div className="crossword-toolbar mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl p-2"><div className="flex gap-1"><button onClick={() => setZoom((value) => Math.max(.7, value - .1))} aria-label="Zoom out">−</button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom((value) => Math.min(1.5, value + .1))} aria-label="Zoom in">+</button></div><span>{layout.entries.length} placed · {layout.omittedEntries.length} omitted</span></div>
|
||||
<div className="crossword-board-viewport rounded-2xl" onKeyDown={handleKeyDown} tabIndex={0} aria-label="Crossword grid">
|
||||
<div className="crossword-board" style={{ "--crossword-cell": `${cellSize}px`, gridTemplateColumns: `repeat(${layout.columns}, var(--crossword-cell))`, gridTemplateRows: `repeat(${layout.rows}, var(--crossword-cell))` } as CSSProperties}>
|
||||
{layout.cells.map((cell) => <button key={cell.key} onClick={() => selectCell(cell.key)} aria-label={`Row ${cell.row + 1}, column ${cell.column + 1}${answers[cell.key] ? `, ${answers[cell.key]}` : ""}`} className={`crossword-cell ${activeKeys.has(cell.key) ? "is-word" : ""} ${activeCellKey === cell.key ? "is-active" : ""} ${checkedWrong.has(cell.key) ? "is-wrong" : ""} ${revealedCells.has(cell.key) ? "is-revealed" : ""}`} style={{ gridRow: cell.row + 1, gridColumn: cell.column + 1 }}><small>{cell.number}</small><strong>{answers[cell.key] ?? ""}</strong></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<button onClick={checkWord} className="crossword-secondary">Check word</button><button onClick={checkPuzzle} className="crossword-secondary">Check puzzle</button>
|
||||
{settings.allowHints && <><button onClick={showAlternateClue} className="crossword-secondary">Alternate clue</button><button onClick={revealLetter} className="crossword-secondary">Reveal letter</button><button onClick={revealWord} className="crossword-secondary">Reveal word</button></>}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3"><button onClick={() => void finish(true)} className="crossword-secondary min-h-12">Give up</button><button onClick={() => void finish(false)} className="crossword-primary min-h-12 rounded-xl font-extrabold">Submit puzzle</button></div>
|
||||
{saveState === "error" && <p className="mt-2 text-sm text-error" role="alert">The attempt could not be saved. Your puzzle is still open.</p>}
|
||||
</div>
|
||||
<aside className="crossword-clues rounded-2xl p-4">
|
||||
<div className="grid grid-cols-2 gap-2">{(["across", "down"] as const).map((tab) => <button key={tab} aria-pressed={clueTab === tab} onClick={() => setClueTab(tab)} className={clueTab === tab ? "is-active" : ""}>{tab}</button>)}</div>
|
||||
<ol className="mt-4 space-y-2">{visibleClues.map((entry) => <li key={entry.id}><button onClick={() => selectEntry(entry)} className={entry.id === activeEntryId ? "is-active" : ""}><b>{entry.number}</b><span>{alternateClues.has(entry.id) ? entry.alternateClue : entry.clue}</span></button></li>)}</ol>
|
||||
</aside>
|
||||
</div>
|
||||
</ArcadeGameShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CrosswordResults({ result, layout, answers, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record<string, string>; exitHref: string }) {
|
||||
const ordered = result.entries.filter((entry) => !entry.omitted).sort((left, right) => {
|
||||
const rank = (entry: typeof left) => !entry.correct ? 0 : entry.revealedWord || entry.revealedLetters > 0 || entry.alternateClueUsed ? 1 : 2;
|
||||
return rank(left) - rank(right);
|
||||
});
|
||||
return <div className="crossword-results">
|
||||
<section className="crossword-results-hero rounded-3xl p-6 sm:p-8"><p className="crossword-kicker">Final edition</p><h2 className="editorial-title mt-1 text-4xl">{result.outcome === "GAVE_UP" ? "Answers revealed" : "Puzzle submitted"}</h2><div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Hints", result.hintsUsed], ["Words", result.placedCount]].map(([label, value]) => <div key={label} className="rounded-xl p-3"><small>{label}</small><strong>{value}</strong></div>)}</div></section>
|
||||
<FinalCrosswordBoard layout={layout} answers={answers} />
|
||||
<div className="mt-5 space-y-3">{ordered.map((entry) => <article key={entry.entryId} className={`crossword-review rounded-2xl p-4 ${entry.correct ? "is-correct" : "is-incorrect"}`}><p className="text-xs font-extrabold uppercase tracking-wide">{entry.revealedWord || entry.revealedLetters ? "Assisted" : entry.correct ? "Correct" : "Incorrect"}</p><h3 className="mt-1 text-lg font-extrabold">{entry.answer}</h3><p className="mt-1 text-sm"><strong>Clue:</strong> {entry.clue}</p><p className="mt-1 text-sm"><strong>Your answer:</strong> {entry.playerAnswer || "No answer"}</p><p className="mt-3 text-sm leading-6">{entry.explanation}</p></article>)}</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={() => window.location.reload()} className="crossword-primary min-h-12 rounded-xl font-extrabold">New layout</button><a href={exitHref} className="crossword-secondary flex min-h-12 items-center justify-center">Back to banks</a></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FinalCrosswordBoard({ layout, answers }: { layout: CrosswordLayout; answers: Record<string, string> }) {
|
||||
const cellSize = Math.max(18, Math.min(30, Math.floor(640 / Math.max(layout.rows, layout.columns))));
|
||||
return <section className="crossword-final-board mt-5 rounded-3xl p-4 sm:p-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3"><div><p className="crossword-kicker">Completed grid</p><h3 className="editorial-title mt-1 text-2xl">Your final board</h3></div><div className="crossword-final-legend"><span><i className="is-correct" />Correct</span><span><i className="is-incorrect" />Incorrect or blank</span></div></div>
|
||||
<div className="crossword-final-viewport mt-4">
|
||||
<div className="crossword-final-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>
|
||||
{layout.cells.map((cell) => {
|
||||
const correct = answers[cell.key] === cell.answer;
|
||||
return <div key={cell.key} className={`crossword-final-cell ${correct ? "is-correct" : "is-incorrect"}`} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} aria-label={`${cell.answer}, ${correct ? "correct" : "incorrect or blank"}`}><small>{cell.number}</small><strong>{cell.answer}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
150
src/components/arcade/CrosswordHub.tsx
Normal file
150
src/components/arcade/CrosswordHub.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
|
||||
import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
|
||||
import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
|
||||
|
||||
const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
|
||||
{ value: "mini", label: "Mini", note: "Up to 15 words" },
|
||||
{ value: "standard", label: "Standard", note: "Up to 30 words" },
|
||||
{ value: "large", label: "Large", note: "Up to 50 words" },
|
||||
{ value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
|
||||
];
|
||||
|
||||
export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [size, setSize] = useState<CrosswordSize>("standard");
|
||||
const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
|
||||
const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
|
||||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||||
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
|
||||
const [previewLayout, setPreviewLayout] = useState<CrosswordLayout | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
|
||||
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
|
||||
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
fetch(`/api/arcade/packs/${selected.id}/attempts`)
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then(setAttempts)
|
||||
.finally(() => setAttemptsLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
let current = true;
|
||||
fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((layout) => { if (current) setPreviewLayout(layout); })
|
||||
.finally(() => { if (current) setPreviewLoading(false); });
|
||||
return () => { current = false; };
|
||||
}, [selected, size]);
|
||||
|
||||
function selectPack(pack: ArcadePackSummary) {
|
||||
setSelectedId(pack.id);
|
||||
setInstantCheck(pack.defaultInstantCheck);
|
||||
setAllowHints(pack.defaultAllowHints);
|
||||
setAttempts([]);
|
||||
setAttemptsLoading(true);
|
||||
setPreviewLayout(null);
|
||||
setPreviewLoading(true);
|
||||
}
|
||||
|
||||
function selectSize(nextSize: CrosswordSize) {
|
||||
setSize(nextSize);
|
||||
setPreviewLayout(null);
|
||||
setPreviewLoading(true);
|
||||
}
|
||||
|
||||
async function renamePack(pack: ArcadePackSummary) {
|
||||
const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
|
||||
if (!name || name === pack.name) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function deletePack(pack: ArcadePackSummary) {
|
||||
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
|
||||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
|
||||
if (selectedId === pack.id) setSelectedId("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const playHref = selected
|
||||
? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="crossword-world crossword-hub p-1 pb-20 sm:p-3">
|
||||
<section className="crossword-hero mb-7 rounded-3xl p-5 sm:flex sm:items-end sm:justify-between sm:p-7">
|
||||
<div>
|
||||
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary">← Back to Arcade</Link>
|
||||
<p className="crossword-kicker">The evening edition</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Crossword</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a terminology bank, set the edition size, and work the clues at your own pace.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank</button>
|
||||
</section>
|
||||
|
||||
{initialPacks.length === 0 ? (
|
||||
<section className="crossword-paper rounded-3xl px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid w-24 grid-cols-5 gap-1" aria-hidden>{Array.from({ length: 25 }, (_, index) => <i key={index} className={index % 3 === 0 ? "bg-[#2d2117]" : "bg-[#fff8e6]"} />)}</div>
|
||||
<h3 className="editorial-title text-3xl text-text-heading">Print your first edition</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Import one JSON object containing exactly 80 clue-and-answer entries.</p>
|
||||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON</button>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(22rem,.9fr)]">
|
||||
<section>
|
||||
<h3 className="crossword-section-label">Puzzle banks</h3>
|
||||
<div className="space-y-3">
|
||||
{initialPacks.map((pack) => {
|
||||
const active = pack.id === effectiveSelectedId;
|
||||
return <article key={pack.id} className={`crossword-pack rounded-2xl border p-4 ${active ? "is-active" : ""}`}>
|
||||
<button onClick={() => selectPack(pack)} className="w-full text-left">
|
||||
<div className="flex items-start justify-between gap-3"><div><h4 className="text-xl font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span>80 entries</span><span>Best {pack.bestScore ?? "—"}</span><span>Latest {pack.latestAttempt?.score ?? "—"}</span></div>
|
||||
</button>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="crossword-setup h-fit rounded-3xl p-5 lg:sticky lg:top-6">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Choose an edition</h3>
|
||||
{selected ? <>
|
||||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||||
<fieldset className="mt-5"><legend className="crossword-section-label">Board size</legend><div className="grid grid-cols-2 gap-2">{SIZES.map((option) => <button type="button" key={option.value} aria-pressed={size === option.value} onClick={() => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}><strong className="block text-sm">{option.label}</strong><span className="text-xs">{option.note}</span></button>)}</div></fieldset>
|
||||
<CrosswordBoardPreview layout={previewLayout} loading={previewLoading} />
|
||||
<div className="mt-5 space-y-2">
|
||||
<label className="crossword-option"><input type="checkbox" checked={instantCheck} onChange={(event) => setInstantCheck(event.target.checked)} /><span><strong>Instant word checks</strong><small>Check only after a word is filled.</small></span></label>
|
||||
<label className="crossword-option"><input type="checkbox" checked={allowHints} onChange={(event) => setAllowHints(event.target.checked)} /><span><strong>Allow hints</strong><small>Alternate clues and reveals stay available.</small></span></label>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between border-y border-border-light py-3 text-sm"><span className="text-text-secondary">Target words</span><strong>{CROSSWORD_SIZE_TARGETS[size]}</strong></div>
|
||||
<Link href={playHref} className="crossword-primary mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Open the puzzle</Link>
|
||||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="crossword-section-label">Recent editions</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history…</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><strong>{attempt.score}/{attempt.maxScore}</strong><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
|
||||
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure a puzzle.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} gameType="crossword" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
|
||||
const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
|
||||
return <section className="crossword-board-preview mt-4" aria-label="Selected size board preview" aria-busy={loading}>
|
||||
<div className="crossword-preview-heading"><span>Layout preview</span>{layout && <small>{layout.entries.length} words · {layout.columns} × {layout.rows}</small>}</div>
|
||||
<div className="crossword-preview-canvas">
|
||||
{loading ? <div className="crossword-preview-loading">Composing puzzle…</div> : layout ? <div className="crossword-preview-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>{layout.cells.map((cell) => <i key={cell.key} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} />)}</div> : <p>Preview unavailable</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
|
||||
import { CrosswordGame } from "@/components/arcade/CrosswordGame";
|
||||
|
||||
export const ARCADE_RENDERERS = {
|
||||
connections: ConnectionsGame,
|
||||
crossword: CrosswordGame,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" }) {
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ export const ARCADE_GAMES = [
|
|||
key: "crossword",
|
||||
name: "Crossword",
|
||||
path: "crossword",
|
||||
available: false,
|
||||
estimatedMinutes: "Coming soon",
|
||||
available: true,
|
||||
estimatedMinutes: "10–45 min",
|
||||
description: "Turn course terminology into an interlocking word puzzle.",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
5
src/lib/arcade/arcadeImport.ts
Normal file
5
src/lib/arcade/arcadeImport.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class ArcadeImportError extends Error {
|
||||
constructor(message: string, public readonly details: string[] = []) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { parseAndRepairJson } from "@/lib/jsonRepair";
|
||||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeImportPreview,
|
||||
|
|
@ -7,11 +8,7 @@ import type {
|
|||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export class ArcadeImportError extends Error {
|
||||
constructor(message: string, public readonly details: string[] = []) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
|
||||
function clean(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
|
|
@ -59,7 +56,7 @@ function findWarnings(pack: NormalizedConnectionsPack) {
|
|||
return [...new Set(warnings)];
|
||||
}
|
||||
|
||||
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview {
|
||||
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedConnectionsPack> {
|
||||
const trimmed = rawJson.trim();
|
||||
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
|
||||
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
|
||||
|
|
@ -95,7 +92,7 @@ export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchP
|
|||
}
|
||||
|
||||
export function parseConnectionsImport(rawJson: string): {
|
||||
preview: ArcadeImportPreview;
|
||||
preview: ArcadeImportPreview<NormalizedConnectionsPack>;
|
||||
report: ArcadeValidationReport;
|
||||
} {
|
||||
const trimmed = rawJson.trim();
|
||||
|
|
@ -165,6 +162,7 @@ export function parseConnectionsImport(rawJson: string): {
|
|||
wasRepaired: repaired.wasRepaired,
|
||||
warnings,
|
||||
normalized,
|
||||
source: repaired.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
34
src/lib/arcade/crosswordAttempt.test.ts
Normal file
34
src/lib/arcade/crosswordAttempt.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replayCrosswordAttempt } from "@/lib/arcade/crosswordAttempt";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
|
||||
|
||||
const settings = { size: "mini" as const, instantCheck: false, allowHints: true };
|
||||
|
||||
describe("Crossword attempt replay", () => {
|
||||
it("scores a perfect unassisted puzzle on the server", () => {
|
||||
const pack = crosswordTestPack();
|
||||
const layout = generateCrosswordLayout(pack, "mini", "perfect");
|
||||
const answers = Object.fromEntries(layout.entries.map((entry) => [entry.id, entry.answer]));
|
||||
const result = replayCrosswordAttempt(pack, "perfect", settings, answers, [], 120, false);
|
||||
expect(result.score).toBe(result.maxScore);
|
||||
expect(result.accuracy).toBe(1);
|
||||
expect(result.placedCount + result.omittedCount).toBe(80);
|
||||
});
|
||||
|
||||
it("applies check and reveal penalties and rejects tampered actions", () => {
|
||||
const pack = crosswordTestPack();
|
||||
const layout = generateCrosswordLayout(pack, "mini", "assisted");
|
||||
const entry = layout.entries[0];
|
||||
const answers = Object.fromEntries(layout.entries.map((item) => [item.id, item.answer]));
|
||||
const result = replayCrosswordAttempt(pack, "assisted", settings, answers, [
|
||||
{ type: "CHECK_WORD", entryId: entry.id, value: "WRONG", elapsedMs: 1000 },
|
||||
{ type: "REVEAL_LETTER", cellKey: entry.cellKeys[0], elapsedMs: 2000 },
|
||||
{ type: "ALTERNATE_CLUE", entryId: entry.id, elapsedMs: 3000 },
|
||||
], 30, false);
|
||||
const assistedWords = layout.cells.find((cell) => cell.key === entry.cellKeys[0])?.entryIds.length ?? 1;
|
||||
expect(result.score).toBe(result.maxScore - 5 - assistedWords * 10);
|
||||
expect(result.hintsUsed).toBe(2);
|
||||
expect(() => replayCrosswordAttempt(pack, "assisted", settings, answers, [{ type: "REVEAL_LETTER", cellKey: "999:999", elapsedMs: 1 }], 1, false)).toThrow("invalid letter reveal");
|
||||
});
|
||||
});
|
||||
116
src/lib/arcade/crosswordAttempt.ts
Normal file
116
src/lib/arcade/crosswordAttempt.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import type {
|
||||
CrosswordAction,
|
||||
CrosswordRoundResult,
|
||||
CrosswordSessionSettings,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function answerKey(value: string) {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
export function replayCrosswordAttempt(
|
||||
pack: NormalizedCrosswordPack,
|
||||
seed: string,
|
||||
settings: CrosswordSessionSettings,
|
||||
finalAnswers: Record<string, string>,
|
||||
actions: CrosswordAction[],
|
||||
durationSeconds: number,
|
||||
gaveUp: boolean
|
||||
): CrosswordRoundResult {
|
||||
const layout = generateCrosswordLayout(pack, settings.size, seed);
|
||||
const entryById = new Map(layout.entries.map((entry) => [entry.id, entry]));
|
||||
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
|
||||
const unknownAnswerId = Object.keys(finalAnswers).find((id) => !entryById.has(id));
|
||||
if (unknownAnswerId) throw new Error("An attempt contains an answer for an entry outside this layout.");
|
||||
|
||||
const revealedCells = new Set<string>();
|
||||
const revealedWords = new Set<string>();
|
||||
const alternateClues = new Set<string>();
|
||||
let incorrectChecks = 0;
|
||||
let hintsUsed = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
if (!Number.isInteger(action.elapsedMs) || action.elapsedMs < 0 || action.elapsedMs > 86_400_000) {
|
||||
throw new Error("An attempt contains an invalid action timestamp.");
|
||||
}
|
||||
if (action.type === "REVEAL_LETTER") {
|
||||
if (!settings.allowHints || !cellByKey.has(action.cellKey)) throw new Error("An attempt contains an invalid letter reveal.");
|
||||
if (!revealedCells.has(action.cellKey)) hintsUsed += 1;
|
||||
revealedCells.add(action.cellKey);
|
||||
continue;
|
||||
}
|
||||
if (action.type === "CHECK_PUZZLE") {
|
||||
if (Object.keys(action.answers).some((id) => !entryById.has(id))) throw new Error("A puzzle check references an unknown entry.");
|
||||
continue;
|
||||
}
|
||||
const entry = entryById.get(action.entryId);
|
||||
if (!entry) throw new Error("An attempt action references an unknown entry.");
|
||||
if (action.type === "CHECK_WORD") {
|
||||
if (answerKey(action.value) !== entry.answer) incorrectChecks += 1;
|
||||
} else if (action.type === "ALTERNATE_CLUE") {
|
||||
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
|
||||
if (!alternateClues.has(entry.id)) hintsUsed += 1;
|
||||
alternateClues.add(entry.id);
|
||||
} else if (action.type === "REVEAL_WORD") {
|
||||
if (!settings.allowHints) throw new Error("Hints were disabled for this attempt.");
|
||||
if (!revealedWords.has(entry.id)) hintsUsed += 1;
|
||||
revealedWords.add(entry.id);
|
||||
entry.cellKeys.forEach((cellKey) => revealedCells.add(cellKey));
|
||||
}
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
let accurateWords = 0;
|
||||
const placedResults = layout.entries.map((entry) => {
|
||||
const playerAnswer = answerKey(finalAnswers[entry.id] ?? "");
|
||||
const correct = playerAnswer === entry.answer || revealedWords.has(entry.id);
|
||||
const revealedLetterCount = entry.cellKeys.filter((cellKey) => revealedCells.has(cellKey)).length;
|
||||
if (correct && !revealedWords.has(entry.id)) {
|
||||
accurateWords += 1;
|
||||
score += Math.max(20, 100 - revealedLetterCount * 10);
|
||||
}
|
||||
return {
|
||||
entryId: entry.id,
|
||||
clue: entry.clue,
|
||||
answer: entry.displayAnswer,
|
||||
playerAnswer: finalAnswers[entry.id] ?? "",
|
||||
explanation: entry.explanation,
|
||||
correct,
|
||||
omitted: false,
|
||||
revealedLetters: revealedLetterCount,
|
||||
revealedWord: revealedWords.has(entry.id),
|
||||
alternateClueUsed: alternateClues.has(entry.id),
|
||||
};
|
||||
});
|
||||
score = Math.max(0, score - incorrectChecks * 5);
|
||||
|
||||
return {
|
||||
outcome: gaveUp ? "GAVE_UP" : "COMPLETED",
|
||||
score,
|
||||
maxScore: layout.entries.length * 100,
|
||||
accuracy: layout.entries.length === 0 ? 0 : accurateWords / layout.entries.length,
|
||||
durationSeconds,
|
||||
mistakes: incorrectChecks,
|
||||
hintsUsed,
|
||||
size: settings.size,
|
||||
placedCount: layout.entries.length,
|
||||
omittedCount: layout.omittedEntries.length,
|
||||
entries: [
|
||||
...placedResults,
|
||||
...layout.omittedEntries.map((entry) => ({
|
||||
entryId: entry.id,
|
||||
clue: entry.clue,
|
||||
answer: entry.displayAnswer,
|
||||
playerAnswer: "",
|
||||
explanation: entry.explanation,
|
||||
correct: false,
|
||||
omitted: true,
|
||||
revealedLetters: 0,
|
||||
revealedWord: false,
|
||||
alternateClueUsed: false,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
26
src/lib/arcade/crosswordEngine.test.ts
Normal file
26
src/lib/arcade/crosswordEngine.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
|
||||
|
||||
describe("Crossword grid engine", () => {
|
||||
it("is deterministic and respects the selected target", () => {
|
||||
const pack = crosswordTestPack();
|
||||
const first = generateCrosswordLayout(pack, "mini", "same-seed");
|
||||
const second = generateCrosswordLayout(pack, "mini", "same-seed");
|
||||
expect(second).toEqual(first);
|
||||
expect(first.entries).toHaveLength(15);
|
||||
expect(first.entries.length).toBeLessThanOrEqual(first.targetCount);
|
||||
expect(first.entries.length + first.omittedEntries.length).toBe(80);
|
||||
});
|
||||
|
||||
it("creates matching cells, shared intersections, and sequential clue numbers", () => {
|
||||
const layout = generateCrosswordLayout(crosswordTestPack(), "standard", "grid-rules");
|
||||
const cellByKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
|
||||
expect(layout.entries.length).toBeGreaterThanOrEqual(15);
|
||||
expect(layout.cells.some((cell) => cell.entryIds.length > 1)).toBe(true);
|
||||
for (const entry of layout.entries) {
|
||||
expect(entry.cellKeys.map((key, index) => cellByKey.get(key)?.answer === entry.answer[index]).every(Boolean)).toBe(true);
|
||||
expect(entry.number).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
266
src/lib/arcade/crosswordEngine.ts
Normal file
266
src/lib/arcade/crosswordEngine.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { deterministicShuffle } from "@/lib/arcade/connectionsEngine";
|
||||
import type {
|
||||
CrosswordCell,
|
||||
CrosswordLayout,
|
||||
CrosswordPlacedEntry,
|
||||
CrosswordSize,
|
||||
NormalizedCrosswordEntry,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export const CROSSWORD_SIZE_TARGETS: Record<CrosswordSize, number> = {
|
||||
mini: 15,
|
||||
standard: 30,
|
||||
large: 50,
|
||||
"extra-large": 80,
|
||||
};
|
||||
|
||||
type Direction = "across" | "down";
|
||||
|
||||
interface WorkingCell {
|
||||
answer: string;
|
||||
directions: Set<Direction>;
|
||||
entryIds: string[];
|
||||
}
|
||||
|
||||
interface WorkingEntry {
|
||||
entry: NormalizedCrosswordEntry;
|
||||
direction: Direction;
|
||||
row: number;
|
||||
column: number;
|
||||
cellKeys: string[];
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
entry: NormalizedCrosswordEntry;
|
||||
direction: Direction;
|
||||
row: number;
|
||||
column: number;
|
||||
intersections: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function key(row: number, column: number) {
|
||||
return `${row}:${column}`;
|
||||
}
|
||||
|
||||
function coordinates(cellKey: string) {
|
||||
const [row, column] = cellKey.split(":").map(Number);
|
||||
return { row, column };
|
||||
}
|
||||
|
||||
function bounds(cells: Map<string, WorkingCell>) {
|
||||
const points = [...cells.keys()].map(coordinates);
|
||||
const rows = points.map((point) => point.row);
|
||||
const columns = points.map((point) => point.column);
|
||||
return {
|
||||
minRow: Math.min(...rows),
|
||||
maxRow: Math.max(...rows),
|
||||
minColumn: Math.min(...columns),
|
||||
maxColumn: Math.max(...columns),
|
||||
};
|
||||
}
|
||||
|
||||
function placementCells(answer: string, row: number, column: number, direction: Direction) {
|
||||
return Array.from(answer, (letter, index) => ({
|
||||
letter,
|
||||
row: row + (direction === "down" ? index : 0),
|
||||
column: column + (direction === "across" ? index : 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function evaluatePlacement(
|
||||
entry: NormalizedCrosswordEntry,
|
||||
row: number,
|
||||
column: number,
|
||||
direction: Direction,
|
||||
cells: Map<string, WorkingCell>
|
||||
): Candidate | null {
|
||||
const positions = placementCells(entry.answer, row, column, direction);
|
||||
const before = direction === "across" ? key(row, column - 1) : key(row - 1, column);
|
||||
const afterPosition = positions[positions.length - 1];
|
||||
const after = direction === "across"
|
||||
? key(afterPosition.row, afterPosition.column + 1)
|
||||
: key(afterPosition.row + 1, afterPosition.column);
|
||||
if (cells.has(before) || cells.has(after)) return null;
|
||||
|
||||
let intersections = 0;
|
||||
for (const position of positions) {
|
||||
const cellKey = key(position.row, position.column);
|
||||
const existing = cells.get(cellKey);
|
||||
if (existing) {
|
||||
if (existing.answer !== position.letter || existing.directions.has(direction)) return null;
|
||||
intersections += 1;
|
||||
continue;
|
||||
}
|
||||
const neighbors = direction === "across"
|
||||
? [key(position.row - 1, position.column), key(position.row + 1, position.column)]
|
||||
: [key(position.row, position.column - 1), key(position.row, position.column + 1)];
|
||||
if (neighbors.some((neighbor) => cells.has(neighbor))) return null;
|
||||
}
|
||||
if (intersections === 0) return null;
|
||||
|
||||
const allKeys = [...cells.keys(), ...positions.map((position) => key(position.row, position.column))];
|
||||
const allPoints = allKeys.map(coordinates);
|
||||
const minRow = Math.min(...allPoints.map((point) => point.row));
|
||||
const maxRow = Math.max(...allPoints.map((point) => point.row));
|
||||
const minColumn = Math.min(...allPoints.map((point) => point.column));
|
||||
const maxColumn = Math.max(...allPoints.map((point) => point.column));
|
||||
const height = maxRow - minRow + 1;
|
||||
const width = maxColumn - minColumn + 1;
|
||||
const score = intersections * 1000 - height * width * 2 - Math.abs(height - width) * 8;
|
||||
return { entry, row, column, direction, intersections, score };
|
||||
}
|
||||
|
||||
function findCandidates(entry: NormalizedCrosswordEntry, cells: Map<string, WorkingCell>) {
|
||||
const candidates: Candidate[] = [];
|
||||
for (const [cellKey, cell] of cells) {
|
||||
const point = coordinates(cellKey);
|
||||
for (let index = 0; index < entry.answer.length; index += 1) {
|
||||
if (entry.answer[index] !== cell.answer) continue;
|
||||
if (!cell.directions.has("across")) {
|
||||
const candidate = evaluatePlacement(entry, point.row, point.column - index, "across", cells);
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
if (!cell.directions.has("down")) {
|
||||
const candidate = evaluatePlacement(entry, point.row - index, point.column, "down", cells);
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function addEntry(candidate: Candidate, cells: Map<string, WorkingCell>, entries: WorkingEntry[]) {
|
||||
const cellKeys: string[] = [];
|
||||
for (const position of placementCells(candidate.entry.answer, candidate.row, candidate.column, candidate.direction)) {
|
||||
const cellKey = key(position.row, position.column);
|
||||
const existing = cells.get(cellKey);
|
||||
if (existing) {
|
||||
existing.directions.add(candidate.direction);
|
||||
existing.entryIds.push(candidate.entry.id);
|
||||
} else {
|
||||
cells.set(cellKey, {
|
||||
answer: position.letter,
|
||||
directions: new Set([candidate.direction]),
|
||||
entryIds: [candidate.entry.id],
|
||||
});
|
||||
}
|
||||
cellKeys.push(cellKey);
|
||||
}
|
||||
entries.push({ entry: candidate.entry, direction: candidate.direction, row: candidate.row, column: candidate.column, cellKeys });
|
||||
}
|
||||
|
||||
function buildCandidate(pack: NormalizedCrosswordPack, targetCount: number, seed: string, pass: number) {
|
||||
const order = deterministicShuffle(pack.content, `${seed}:entries:${pass}`);
|
||||
const firstPool = order.slice(0, Math.min(12, order.length)).sort((a, b) => b.answer.length - a.answer.length);
|
||||
const first = firstPool[pass % firstPool.length];
|
||||
const cells = new Map<string, WorkingCell>();
|
||||
const entries: WorkingEntry[] = [];
|
||||
addEntry({ entry: first, direction: pass % 2 === 0 ? "across" : "down", row: 0, column: 0, intersections: 0, score: 0 }, cells, entries);
|
||||
const remaining = order.filter((entry) => entry.id !== first.id);
|
||||
|
||||
while (entries.length < targetCount && remaining.length > 0) {
|
||||
const candidates: Candidate[] = [];
|
||||
let entriesWithCandidates = 0;
|
||||
for (const entry of remaining) {
|
||||
const entryCandidates = findCandidates(entry, cells);
|
||||
if (entryCandidates.length === 0) continue;
|
||||
entryCandidates.sort((left, right) => right.score - left.score);
|
||||
candidates.push(...entryCandidates.slice(0, 3));
|
||||
entriesWithCandidates += 1;
|
||||
if (entriesWithCandidates >= 12) break;
|
||||
}
|
||||
if (candidates.length === 0) break;
|
||||
candidates.sort((left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id));
|
||||
const top = candidates.slice(0, Math.min(8, candidates.length));
|
||||
const selected = deterministicShuffle(top, `${seed}:choice:${pass}:${entries.length}`)[0];
|
||||
addEntry(selected, cells, entries);
|
||||
const usedIndex = remaining.findIndex((entry) => entry.id === selected.entry.id);
|
||||
remaining.splice(usedIndex, 1);
|
||||
}
|
||||
return { cells, entries };
|
||||
}
|
||||
|
||||
function candidateScore(candidate: ReturnType<typeof buildCandidate>) {
|
||||
const box = bounds(candidate.cells);
|
||||
const height = box.maxRow - box.minRow + 1;
|
||||
const width = box.maxColumn - box.minColumn + 1;
|
||||
const intersections = [...candidate.cells.values()].filter((cell) => cell.directions.size > 1).length;
|
||||
return candidate.entries.length * 1_000_000 + intersections * 10_000 - height * width * 10 - Math.abs(height - width) * 20;
|
||||
}
|
||||
|
||||
function finalizeLayout(
|
||||
pack: NormalizedCrosswordPack,
|
||||
size: CrosswordSize,
|
||||
seed: string,
|
||||
candidate: ReturnType<typeof buildCandidate>
|
||||
): CrosswordLayout {
|
||||
const box = bounds(candidate.cells);
|
||||
const offsetRow = -box.minRow;
|
||||
const offsetColumn = -box.minColumn;
|
||||
const starts = new Map<string, number>();
|
||||
const sortedStarts = candidate.entries
|
||||
.map((placed) => ({ key: key(placed.row + offsetRow, placed.column + offsetColumn), row: placed.row + offsetRow, column: placed.column + offsetColumn }))
|
||||
.sort((left, right) => left.row - right.row || left.column - right.column);
|
||||
for (const start of sortedStarts) {
|
||||
if (!starts.has(start.key)) starts.set(start.key, starts.size + 1);
|
||||
}
|
||||
|
||||
const entries: CrosswordPlacedEntry[] = candidate.entries.map((placed) => {
|
||||
const row = placed.row + offsetRow;
|
||||
const column = placed.column + offsetColumn;
|
||||
return {
|
||||
...placed.entry,
|
||||
direction: placed.direction,
|
||||
row,
|
||||
column,
|
||||
number: starts.get(key(row, column)) ?? 0,
|
||||
cellKeys: placed.cellKeys.map((cellKey) => {
|
||||
const point = coordinates(cellKey);
|
||||
return key(point.row + offsetRow, point.column + offsetColumn);
|
||||
}),
|
||||
};
|
||||
});
|
||||
const placedIds = new Set(entries.map((entry) => entry.id));
|
||||
const cells: CrosswordCell[] = [...candidate.cells.entries()].map(([cellKey, cell]) => {
|
||||
const point = coordinates(cellKey);
|
||||
const normalizedKey = key(point.row + offsetRow, point.column + offsetColumn);
|
||||
return {
|
||||
key: normalizedKey,
|
||||
row: point.row + offsetRow,
|
||||
column: point.column + offsetColumn,
|
||||
answer: cell.answer,
|
||||
number: starts.get(normalizedKey),
|
||||
entryIds: cell.entryIds,
|
||||
};
|
||||
}).sort((left, right) => left.row - right.row || left.column - right.column);
|
||||
return {
|
||||
size,
|
||||
seed,
|
||||
targetCount: CROSSWORD_SIZE_TARGETS[size],
|
||||
rows: box.maxRow - box.minRow + 1,
|
||||
columns: box.maxColumn - box.minColumn + 1,
|
||||
cells,
|
||||
entries,
|
||||
omittedEntries: pack.content.filter((entry) => !placedIds.has(entry.id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateCrosswordLayout(pack: NormalizedCrosswordPack, size: CrosswordSize, seed: string) {
|
||||
const targetCount = CROSSWORD_SIZE_TARGETS[size];
|
||||
let best = buildCandidate(pack, targetCount, seed, 0);
|
||||
let bestScore = candidateScore(best);
|
||||
if (best.entries.length === targetCount) return finalizeLayout(pack, size, seed, best);
|
||||
|
||||
for (let pass = 1; pass < 250; pass += 1) {
|
||||
const candidate = buildCandidate(pack, targetCount, seed, pass);
|
||||
const score = candidateScore(candidate);
|
||||
if (score > bestScore) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
}
|
||||
if (best.entries.length === targetCount) break;
|
||||
}
|
||||
return finalizeLayout(pack, size, seed, best);
|
||||
}
|
||||
47
src/lib/arcade/crosswordImport.test.ts
Normal file
47
src/lib/arcade/crosswordImport.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseCrosswordImport } from "@/lib/arcade/crosswordImport";
|
||||
import { crosswordTestPack } from "@/lib/arcade/crosswordTestData";
|
||||
|
||||
function importObject() {
|
||||
const pack = crosswordTestPack();
|
||||
return {
|
||||
...pack,
|
||||
content: pack.content.map(({ answer, displayAnswer, clue, alternateClue, explanation }, index) => ({
|
||||
id: `term-${index + 1}`,
|
||||
answer: index === 0 ? "Study-AA Word" : answer,
|
||||
displayAnswer,
|
||||
clue,
|
||||
alternateClue,
|
||||
explanation,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Crossword imports", () => {
|
||||
it("normalizes one valid 80-entry pack and previews every size", () => {
|
||||
const result = parseCrosswordImport(JSON.stringify(importObject()));
|
||||
expect(result.preview.itemCount).toBe(80);
|
||||
expect(result.preview.normalized.content[0].answer).toBe("STUDYAAWORD");
|
||||
expect(result.preview.layoutPreviews?.map((layout) => layout.size)).toEqual(["mini", "standard", "large", "extra-large"]);
|
||||
});
|
||||
|
||||
it("rejects packs with fewer or more than 80 entries", () => {
|
||||
const short = importObject();
|
||||
short.content.pop();
|
||||
expect(() => parseCrosswordImport(JSON.stringify(short))).toThrow("exactly 80 entries");
|
||||
const long = importObject();
|
||||
long.content.push({ ...long.content[0], id: "extra", answer: "EXTRATERM" });
|
||||
expect(() => parseCrosswordImport(JSON.stringify(long))).toThrow("exactly 80 entries");
|
||||
});
|
||||
|
||||
it("rejects arrays, normalized duplicates, digits, and unknown keys", () => {
|
||||
expect(() => parseCrosswordImport(JSON.stringify([importObject()]))).toThrow("one raw Crossword JSON object");
|
||||
const duplicate = importObject();
|
||||
duplicate.content[1].answer = "study aa word";
|
||||
expect(() => parseCrosswordImport(JSON.stringify(duplicate))).toThrow("duplicated");
|
||||
const digits = importObject();
|
||||
digits.content[0].answer = "TERM2";
|
||||
expect(() => parseCrosswordImport(JSON.stringify(digits))).toThrow("digits");
|
||||
expect(() => parseCrosswordImport(JSON.stringify({ ...importObject(), extra: true }))).toThrow("does not match schema");
|
||||
});
|
||||
});
|
||||
126
src/lib/arcade/crosswordImport.ts
Normal file
126
src/lib/arcade/crosswordImport.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
|
||||
import { CROSSWORD_SIZE_TARGETS, generateCrosswordLayout } from "@/lib/arcade/crosswordEngine";
|
||||
import { parseAndRepairJson } from "@/lib/jsonRepair";
|
||||
import { crosswordImportSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeImportBatchPreview,
|
||||
ArcadeImportPreview,
|
||||
ArcadeValidationReport,
|
||||
CrosswordLayoutPreview,
|
||||
CrosswordSize,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function clean(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function safeId(value: string, fallback: string) {
|
||||
const id = value.trim().toLocaleLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
||||
return id || fallback;
|
||||
}
|
||||
|
||||
export function normalizeCrosswordAnswer(value: string) {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
export function parseCrosswordImport(rawJson: string): {
|
||||
preview: ArcadeImportPreview<NormalizedCrosswordPack>;
|
||||
report: ArcadeValidationReport;
|
||||
} {
|
||||
const trimmed = rawJson.trim();
|
||||
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
throw new ArcadeImportError("Paste one raw Crossword JSON object without Markdown fences, an array, or surrounding prose.");
|
||||
}
|
||||
const repaired = parseAndRepairJson(trimmed);
|
||||
if (!repaired.success) throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
|
||||
if (Array.isArray(repaired.data)) throw new ArcadeImportError("Crossword imports accept one pack object at a time.");
|
||||
const parsed = crosswordImportSchema.safeParse(repaired.data);
|
||||
if (!parsed.success) {
|
||||
throw new ArcadeImportError(
|
||||
"The Crossword pack does not match schema version 1 and must contain exactly 80 entries.",
|
||||
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
const usedAnswers = new Set<string>();
|
||||
const normalized: NormalizedCrosswordPack = {
|
||||
schemaVersion: 1,
|
||||
type: "crossword",
|
||||
name: clean(parsed.data.name),
|
||||
description: parsed.data.description ? clean(parsed.data.description) : undefined,
|
||||
settings: { ...parsed.data.settings },
|
||||
content: parsed.data.content.map((entry, index) => {
|
||||
if (/\d/.test(entry.answer)) throw new ArcadeImportError(`Entry ${index + 1} uses digits, which Crossword answers do not support.`);
|
||||
const answer = normalizeCrosswordAnswer(entry.answer);
|
||||
if (answer.length < 3 || answer.length > 18) {
|
||||
throw new ArcadeImportError(`Entry ${index + 1} must contain 3 to 18 letters after normalization.`);
|
||||
}
|
||||
if (usedAnswers.has(answer)) throw new ArcadeImportError(`The normalized answer “${answer}” is duplicated.`);
|
||||
usedAnswers.add(answer);
|
||||
const fallbackId = `entry-${index + 1}`;
|
||||
const id = safeId(entry.id ?? fallbackId, fallbackId);
|
||||
if (usedIds.has(id)) throw new ArcadeImportError(`Entry id “${id}” is duplicated.`);
|
||||
usedIds.add(id);
|
||||
if (answer !== entry.answer.trim().toUpperCase()) warnings.push(`“${entry.answer}” will be placed as “${answer}”.`);
|
||||
if (entry.clue.length > 180) warnings.push(`The clue for “${entry.displayAnswer ?? entry.answer}” is longer than 180 characters.`);
|
||||
return {
|
||||
id,
|
||||
answer,
|
||||
displayAnswer: clean(entry.displayAnswer ?? entry.answer),
|
||||
clue: entry.clue.trim(),
|
||||
alternateClue: entry.alternateClue.trim(),
|
||||
explanation: entry.explanation.trim(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const sizes: CrosswordSize[] = ["mini", "standard", "large", "extra-large"];
|
||||
const layoutPreviews: CrosswordLayoutPreview[] = sizes.map((size) => {
|
||||
const layout = generateCrosswordLayout(normalized, size, "crossword-import-preview-v1");
|
||||
return {
|
||||
size,
|
||||
targetCount: CROSSWORD_SIZE_TARGETS[size],
|
||||
placedCount: layout.entries.length,
|
||||
omittedCount: layout.omittedEntries.length,
|
||||
rows: layout.rows,
|
||||
columns: layout.columns,
|
||||
};
|
||||
});
|
||||
const mini = layoutPreviews[0];
|
||||
if (mini.placedCount < 15) {
|
||||
throw new ArcadeImportError(
|
||||
"This pack cannot form a connected 15-word Mini crossword.",
|
||||
[`The best preview placed ${mini.placedCount} of 15 target entries. Use answers with more shared letters.`]
|
||||
);
|
||||
}
|
||||
for (const layout of layoutPreviews) {
|
||||
if (layout.placedCount < layout.targetCount) {
|
||||
warnings.push(`${layout.size} preview placed ${layout.placedCount} of ${layout.targetCount} target words.`);
|
||||
}
|
||||
}
|
||||
const uniqueWarnings = [...new Set(warnings)];
|
||||
const report = { wasRepaired: repaired.wasRepaired, warnings: uniqueWarnings };
|
||||
return {
|
||||
report,
|
||||
preview: {
|
||||
gameType: "crossword",
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
categories: [],
|
||||
itemCount: 80,
|
||||
wasRepaired: repaired.wasRepaired,
|
||||
warnings: uniqueWarnings,
|
||||
normalized,
|
||||
source: repaired.data,
|
||||
layoutPreviews,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCrosswordImportBatch(rawJson: string): ArcadeImportBatchPreview<NormalizedCrosswordPack> {
|
||||
const parsed = parseCrosswordImport(rawJson);
|
||||
return { packs: [parsed.preview], count: 1, wasRepaired: parsed.preview.wasRepaired };
|
||||
}
|
||||
22
src/lib/arcade/crosswordTestData.ts
Normal file
22
src/lib/arcade/crosswordTestData.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { NormalizedCrosswordPack } from "@/types/arcade";
|
||||
|
||||
function letters(index: number) {
|
||||
return `${String.fromCharCode(65 + Math.floor(index / 26))}${String.fromCharCode(65 + (index % 26))}`;
|
||||
}
|
||||
|
||||
export function crosswordTestPack(): NormalizedCrosswordPack {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "crossword",
|
||||
name: "Test Crossword",
|
||||
settings: { allowInstantCheck: false, allowHints: true },
|
||||
content: Array.from({ length: 80 }, (_, index) => ({
|
||||
id: `entry-${index + 1}`,
|
||||
answer: `STUDY${letters(index)}WORD`,
|
||||
displayAnswer: `Study ${letters(index)} Word`,
|
||||
clue: `Test clue ${index + 1}`,
|
||||
alternateClue: `Alternate test clue ${index + 1}`,
|
||||
explanation: `Explanation ${index + 1}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
import { parseCrosswordImportBatch } from "@/lib/arcade/crosswordImport";
|
||||
import type { ArcadeGameKey } from "@/types/arcade";
|
||||
|
||||
const CONNECTIONS_PROMPT = `You are generating a Connections study game as strict JSON.
|
||||
|
|
@ -18,15 +19,39 @@ Rules:
|
|||
Material:
|
||||
[paste your notes or lecture content here]`;
|
||||
|
||||
const CROSSWORD_PROMPT = `You are generating one Crossword study game as strict JSON.
|
||||
Return exactly one JSON object with no Markdown fence or commentary.
|
||||
|
||||
Use this schema:
|
||||
{"schemaVersion":1,"type":"crossword","name":"...","description":"...","settings":{"allowInstantCheck":false,"allowHints":true},"content":[{"id":"entry-1","answer":"...","displayAnswer":"...","clue":"...","alternateClue":"...","explanation":"..."}]}
|
||||
|
||||
Rules:
|
||||
- Generate exactly 80 entries in the content array.
|
||||
- Every answer must be unique after spaces, punctuation, hyphens, apostrophes, and capitalization are removed.
|
||||
- Answers must contain 3 to 18 letters after normalization. Do not use digits.
|
||||
- Prefer terminology with shared letters so the application can construct a dense connected crossword.
|
||||
- Each clue must uniquely identify its answer without repeating the answer.
|
||||
- Provide a genuinely useful alternate clue and a concise educational explanation for every entry.
|
||||
- Use displayAnswer to preserve spaces, punctuation, or natural capitalization when needed.
|
||||
- Base all content strictly on the study material below.
|
||||
|
||||
Material:
|
||||
[paste your notes or lecture content here]`;
|
||||
|
||||
export const ARCADE_SERVER_REGISTRY = {
|
||||
connections: {
|
||||
schemaVersion: 1,
|
||||
preview: parseConnectionsImportBatch,
|
||||
defaultInstructions: CONNECTIONS_PROMPT,
|
||||
},
|
||||
crossword: {
|
||||
schemaVersion: 1,
|
||||
preview: parseCrosswordImportBatch,
|
||||
defaultInstructions: CROSSWORD_PROMPT,
|
||||
},
|
||||
} satisfies Record<ArcadeGameKey, {
|
||||
schemaVersion: number;
|
||||
preview: typeof parseConnectionsImportBatch;
|
||||
preview: (rawJson: string) => unknown;
|
||||
defaultInstructions: string;
|
||||
}>;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,12 +27,35 @@ export const connectionsImportSchema = z.object({
|
|||
content: z.array(connectionGroupSchema).length(4),
|
||||
}).strict();
|
||||
|
||||
const crosswordEntrySchema = z.object({
|
||||
id: z.string().trim().min(1).max(100).optional(),
|
||||
answer: z.string().trim().min(1).max(80),
|
||||
displayAnswer: z.string().trim().min(1).max(80).optional(),
|
||||
clue: z.string().trim().min(1).max(500),
|
||||
alternateClue: z.string().trim().min(1).max(500),
|
||||
explanation: z.string().trim().min(1).max(1500),
|
||||
}).strict();
|
||||
|
||||
export const crosswordImportSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
type: z.literal("crossword"),
|
||||
name: z.string().trim().min(1).max(150),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
settings: z.object({
|
||||
allowInstantCheck: z.boolean(),
|
||||
allowHints: z.boolean(),
|
||||
}).strict(),
|
||||
content: z.array(crosswordEntrySchema).length(80),
|
||||
}).strict();
|
||||
|
||||
export const arcadePreviewRequestSchema = z.object({
|
||||
gameType: z.literal("connections"),
|
||||
gameType: z.union([z.literal("connections"), z.literal("crossword")]),
|
||||
rawJson: z.string().min(1),
|
||||
});
|
||||
|
||||
export const arcadePackCreateSchema = arcadePreviewRequestSchema.extend({
|
||||
export const arcadePackCreateSchema = z.object({
|
||||
gameType: z.union([z.literal("connections"), z.literal("crossword")]),
|
||||
rawJson: z.string().min(1),
|
||||
classId: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(150).optional(),
|
||||
names: z.array(z.string().trim().min(1).max(150)).min(1).max(10).optional(),
|
||||
|
|
@ -57,5 +80,28 @@ export const arcadeAttemptCreateSchema = z.object({
|
|||
}).strict()).max(50),
|
||||
});
|
||||
|
||||
const crosswordActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("CHECK_WORD"), entryId: z.string().min(1), value: z.string(), elapsedMs: z.number().int() }).strict(),
|
||||
z.object({ type: z.literal("CHECK_PUZZLE"), answers: z.record(z.string(), z.string()), elapsedMs: z.number().int() }).strict(),
|
||||
z.object({ type: z.literal("ALTERNATE_CLUE"), entryId: z.string().min(1), elapsedMs: z.number().int() }).strict(),
|
||||
z.object({ type: z.literal("REVEAL_LETTER"), cellKey: z.string().regex(/^-?\d+:-?\d+$/), elapsedMs: z.number().int() }).strict(),
|
||||
z.object({ type: z.literal("REVEAL_WORD"), entryId: z.string().min(1), elapsedMs: z.number().int() }).strict(),
|
||||
]);
|
||||
|
||||
export const crosswordAttemptCreateSchema = z.object({
|
||||
mode: z.literal("CROSSWORD").default("CROSSWORD"),
|
||||
durationSeconds: z.number().int().min(0).max(86400),
|
||||
seed: z.string().min(1).max(100),
|
||||
settings: z.object({
|
||||
size: z.union([z.literal("mini"), z.literal("standard"), z.literal("large"), z.literal("extra-large")]),
|
||||
instantCheck: z.boolean(),
|
||||
allowHints: z.boolean(),
|
||||
}).strict(),
|
||||
finalAnswers: z.record(z.string(), z.string()),
|
||||
actions: z.array(crosswordActionSchema).max(1000),
|
||||
gaveUp: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export type ConnectionsImportInput = z.infer<typeof connectionsImportSchema>;
|
||||
export type ArcadeAttemptCreateInput = z.infer<typeof arcadeAttemptCreateSchema>;
|
||||
export type CrosswordAttemptCreateInput = z.infer<typeof crosswordAttemptCreateSchema>;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
import { replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
|
||||
import type { ArcadeAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
|
||||
import { parseCrosswordImportBatch } from "@/lib/arcade/crosswordImport";
|
||||
import { replayCrosswordAttempt } from "@/lib/arcade/crosswordAttempt";
|
||||
import type { ArcadeAttemptCreateInput, CrosswordAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeAttemptSummary,
|
||||
ArcadeGameKey,
|
||||
ArcadePackSummary,
|
||||
NormalizedArcadePack,
|
||||
NormalizedConnectionsPack,
|
||||
NormalizedCrosswordPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function attemptSummary(attempt: {
|
||||
|
|
@ -31,8 +35,12 @@ function attemptSummary(attempt: {
|
|||
};
|
||||
}
|
||||
|
||||
export function previewArcadeImport(rawJson: string) {
|
||||
return parseConnectionsImportBatch(rawJson);
|
||||
function parseImport(gameType: ArcadeGameKey, rawJson: string) {
|
||||
return gameType === "connections" ? parseConnectionsImportBatch(rawJson) : parseCrosswordImportBatch(rawJson);
|
||||
}
|
||||
|
||||
export function previewArcadeImport(gameType: ArcadeGameKey, rawJson: string) {
|
||||
return parseImport(gameType, rawJson);
|
||||
}
|
||||
|
||||
export async function createArcadePacks(input: {
|
||||
|
|
@ -46,7 +54,7 @@ export async function createArcadePacks(input: {
|
|||
const classItem = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
|
||||
if (!classItem) throw new Error("Class not found");
|
||||
|
||||
const batch = parseConnectionsImportBatch(input.rawJson);
|
||||
const batch = parseImport(input.gameType, input.rawJson);
|
||||
if (input.names && input.names.length !== batch.count) {
|
||||
throw new Error("A name is required for every imported pack.");
|
||||
}
|
||||
|
|
@ -67,7 +75,7 @@ export async function createArcadePacks(input: {
|
|||
name: input.names?.[index]?.trim() || (batch.count === 1 ? input.name?.trim() : undefined) || preview.name,
|
||||
description: preview.description,
|
||||
schemaVersion: preview.normalized.schemaVersion,
|
||||
sourceJson: JSON.stringify(preview.normalized),
|
||||
sourceJson: JSON.stringify(preview.source),
|
||||
normalizedJson: JSON.stringify(preview.normalized),
|
||||
validationReportJson: JSON.stringify({ wasRepaired: preview.wasRepaired, warnings: preview.warnings }),
|
||||
sortOrder: (maxOrder._max.sortOrder ?? -1) + index + 1,
|
||||
|
|
@ -85,7 +93,8 @@ export async function listArcadePacks(classId: string, gameType: ArcadeGameKey):
|
|||
include: { attempts: { orderBy: { completedAt: "desc" } } },
|
||||
});
|
||||
return packs.map((pack) => {
|
||||
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
|
||||
const normalized = JSON.parse(pack.normalizedJson) as NormalizedArcadePack;
|
||||
const isConnections = normalized.type === "connections";
|
||||
return {
|
||||
id: pack.id,
|
||||
classId: pack.classId,
|
||||
|
|
@ -93,8 +102,10 @@ export async function listArcadePacks(classId: string, gameType: ArcadeGameKey):
|
|||
name: pack.name,
|
||||
description: pack.description,
|
||||
schemaVersion: pack.schemaVersion,
|
||||
itemCount: normalized.content.length * 4,
|
||||
defaultAllowedMistakes: normalized.settings.allowedMistakes,
|
||||
itemCount: isConnections ? normalized.content.length * 4 : normalized.content.length,
|
||||
defaultAllowedMistakes: isConnections ? normalized.settings.allowedMistakes : 4,
|
||||
defaultInstantCheck: isConnections ? false : normalized.settings.allowInstantCheck,
|
||||
defaultAllowHints: isConnections ? true : normalized.settings.allowHints,
|
||||
bestScore: pack.attempts.length ? Math.max(...pack.attempts.map((attempt) => attempt.score)) : null,
|
||||
latestAttempt: pack.attempts[0] ? attemptSummary(pack.attempts[0]) : null,
|
||||
};
|
||||
|
|
@ -110,7 +121,7 @@ export async function getArcadePack(id: string) {
|
|||
return {
|
||||
...pack,
|
||||
gameType: pack.gameType as ArcadeGameKey,
|
||||
normalized: JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack,
|
||||
normalized: JSON.parse(pack.normalizedJson) as NormalizedArcadePack,
|
||||
validationReport: JSON.parse(pack.validationReportJson),
|
||||
};
|
||||
}
|
||||
|
|
@ -141,6 +152,7 @@ export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAtt
|
|||
const pack = await prisma.arcadePack.findUnique({ where: { id: arcadePackId } });
|
||||
if (!pack) return null;
|
||||
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
|
||||
if (normalized.type !== "connections") throw new Error("This attempt does not match the pack game type.");
|
||||
const replay = replayConnectionsAttempt(
|
||||
normalized,
|
||||
input.submissions,
|
||||
|
|
@ -174,3 +186,43 @@ export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAtt
|
|||
return attemptSummary({ ...attempt, resultsJson: attempt.resultsJson });
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCrosswordAttempt(arcadePackId: string, input: CrosswordAttemptCreateInput) {
|
||||
const pack = await prisma.arcadePack.findUnique({ where: { id: arcadePackId } });
|
||||
if (!pack) return null;
|
||||
const normalized = JSON.parse(pack.normalizedJson) as NormalizedCrosswordPack;
|
||||
if (normalized.type !== "crossword") throw new Error("This attempt does not match the pack game type.");
|
||||
const result = replayCrosswordAttempt(
|
||||
normalized,
|
||||
input.seed,
|
||||
input.settings,
|
||||
input.finalAnswers,
|
||||
input.actions,
|
||||
input.durationSeconds,
|
||||
input.gaveUp
|
||||
);
|
||||
const correctWords = result.entries.filter((entry) => !entry.omitted && entry.correct && !entry.revealedWord).length;
|
||||
return prisma.$transaction(async (transaction) => {
|
||||
const attempt = await transaction.arcadeAttempt.create({
|
||||
data: {
|
||||
arcadePackId,
|
||||
mode: input.mode,
|
||||
score: result.score,
|
||||
maxScore: result.maxScore,
|
||||
accuracy: result.accuracy,
|
||||
durationSeconds: result.durationSeconds,
|
||||
mistakes: result.mistakes,
|
||||
hintsUsed: result.hintsUsed,
|
||||
settingsJson: JSON.stringify(input.settings),
|
||||
resultsJson: JSON.stringify(result),
|
||||
seed: input.seed,
|
||||
},
|
||||
});
|
||||
if (correctWords > 0) {
|
||||
await transaction.studyActivity.createMany({
|
||||
data: Array.from({ length: correctWords }, () => ({ type: "ARCADE_WORD" })),
|
||||
});
|
||||
}
|
||||
return attemptSummary({ ...attempt, resultsJson: attempt.resultsJson });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
import { ARCADE_SERVER_REGISTRY } from "@/lib/arcade/registry";
|
||||
|
||||
export type LlmInstructionType = "flashcards" | "quizzes" | "connections";
|
||||
export type LlmInstructionType = "flashcards" | "quizzes" | "connections" | "crossword";
|
||||
|
||||
const DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS = `You are generating study materials in a strict JSON format for import into a
|
||||
personal study app. The overall output must be raw JSON with no wrapping
|
||||
|
|
@ -54,7 +54,8 @@ Material:
|
|||
function instructionConfig(type: LlmInstructionType) {
|
||||
if (type === "flashcards") return { key: "llmInstructionsFlashcards", value: DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS };
|
||||
if (type === "quizzes") return { key: "llmInstructionsQuizzes", value: DEFAULT_LLM_INSTRUCTIONS_QUIZZES };
|
||||
return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
|
||||
if (type === "connections") return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
|
||||
return { key: "llmInstructionsCrossword", value: ARCADE_SERVER_REGISTRY.crossword.defaultInstructions };
|
||||
}
|
||||
|
||||
export async function getLlmInstructions(type: LlmInstructionType): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export type ArcadeGameKey = "connections";
|
||||
export type ArcadeGameKey = "connections" | "crossword";
|
||||
export type CrosswordSize = "mini" | "standard" | "large" | "extra-large";
|
||||
|
||||
export interface ConnectionsImportGroup {
|
||||
id?: string;
|
||||
|
|
@ -42,12 +43,65 @@ export interface NormalizedConnectionsPack {
|
|||
content: NormalizedConnectionsGroup[];
|
||||
}
|
||||
|
||||
export interface CrosswordImportEntry {
|
||||
id?: string;
|
||||
answer: string;
|
||||
displayAnswer?: string;
|
||||
clue: string;
|
||||
alternateClue: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface CrosswordImportData {
|
||||
schemaVersion: 1;
|
||||
type: "crossword";
|
||||
name: string;
|
||||
description?: string;
|
||||
settings: {
|
||||
allowInstantCheck: boolean;
|
||||
allowHints: boolean;
|
||||
};
|
||||
content: CrosswordImportEntry[];
|
||||
}
|
||||
|
||||
export interface NormalizedCrosswordEntry {
|
||||
id: string;
|
||||
answer: string;
|
||||
displayAnswer: string;
|
||||
clue: string;
|
||||
alternateClue: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface NormalizedCrosswordPack {
|
||||
schemaVersion: 1;
|
||||
type: "crossword";
|
||||
name: string;
|
||||
description?: string;
|
||||
settings: {
|
||||
allowInstantCheck: boolean;
|
||||
allowHints: boolean;
|
||||
};
|
||||
content: NormalizedCrosswordEntry[];
|
||||
}
|
||||
|
||||
export type NormalizedArcadePack = NormalizedConnectionsPack | NormalizedCrosswordPack;
|
||||
|
||||
export interface CrosswordLayoutPreview {
|
||||
size: CrosswordSize;
|
||||
targetCount: number;
|
||||
placedCount: number;
|
||||
omittedCount: number;
|
||||
rows: number;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
export interface ArcadeValidationReport {
|
||||
wasRepaired: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ArcadeImportPreview {
|
||||
export interface ArcadeImportPreview<TPack extends NormalizedArcadePack = NormalizedArcadePack> {
|
||||
gameType: ArcadeGameKey;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
|
@ -55,11 +109,13 @@ export interface ArcadeImportPreview {
|
|||
itemCount: number;
|
||||
wasRepaired: boolean;
|
||||
warnings: string[];
|
||||
normalized: NormalizedConnectionsPack;
|
||||
normalized: TPack;
|
||||
source: unknown;
|
||||
layoutPreviews?: CrosswordLayoutPreview[];
|
||||
}
|
||||
|
||||
export interface ArcadeImportBatchPreview {
|
||||
packs: ArcadeImportPreview[];
|
||||
export interface ArcadeImportBatchPreview<TPack extends NormalizedArcadePack = NormalizedArcadePack> {
|
||||
packs: ArcadeImportPreview<TPack>[];
|
||||
count: number;
|
||||
wasRepaired: boolean;
|
||||
}
|
||||
|
|
@ -69,6 +125,74 @@ export interface ArcadeSessionSettings {
|
|||
oneAwayFeedback: boolean;
|
||||
}
|
||||
|
||||
export interface CrosswordSessionSettings {
|
||||
size: CrosswordSize;
|
||||
instantCheck: boolean;
|
||||
allowHints: boolean;
|
||||
}
|
||||
|
||||
export interface CrosswordPlacedEntry extends NormalizedCrosswordEntry {
|
||||
direction: "across" | "down";
|
||||
row: number;
|
||||
column: number;
|
||||
number: number;
|
||||
cellKeys: string[];
|
||||
}
|
||||
|
||||
export interface CrosswordCell {
|
||||
key: string;
|
||||
row: number;
|
||||
column: number;
|
||||
answer: string;
|
||||
number?: number;
|
||||
entryIds: string[];
|
||||
}
|
||||
|
||||
export interface CrosswordLayout {
|
||||
size: CrosswordSize;
|
||||
seed: string;
|
||||
targetCount: number;
|
||||
rows: number;
|
||||
columns: number;
|
||||
cells: CrosswordCell[];
|
||||
entries: CrosswordPlacedEntry[];
|
||||
omittedEntries: NormalizedCrosswordEntry[];
|
||||
}
|
||||
|
||||
export type CrosswordAction =
|
||||
| { type: "CHECK_WORD"; entryId: string; value: string; elapsedMs: number }
|
||||
| { type: "CHECK_PUZZLE"; answers: Record<string, string>; elapsedMs: number }
|
||||
| { type: "ALTERNATE_CLUE"; entryId: string; elapsedMs: number }
|
||||
| { type: "REVEAL_LETTER"; cellKey: string; elapsedMs: number }
|
||||
| { type: "REVEAL_WORD"; entryId: string; elapsedMs: number };
|
||||
|
||||
export interface CrosswordEntryResult {
|
||||
entryId: string;
|
||||
clue: string;
|
||||
answer: string;
|
||||
playerAnswer: string;
|
||||
explanation: string;
|
||||
correct: boolean;
|
||||
omitted: boolean;
|
||||
revealedLetters: number;
|
||||
revealedWord: boolean;
|
||||
alternateClueUsed: boolean;
|
||||
}
|
||||
|
||||
export interface CrosswordRoundResult {
|
||||
outcome: "COMPLETED" | "GAVE_UP";
|
||||
score: number;
|
||||
maxScore: number;
|
||||
accuracy: number;
|
||||
durationSeconds: number;
|
||||
mistakes: number;
|
||||
hintsUsed: number;
|
||||
size: CrosswordSize;
|
||||
placedCount: number;
|
||||
omittedCount: number;
|
||||
entries: CrosswordEntryResult[];
|
||||
}
|
||||
|
||||
export interface ConnectionsSubmission {
|
||||
itemIds: string[];
|
||||
elapsedMs: number;
|
||||
|
|
@ -106,7 +230,7 @@ export interface ArcadeAttemptSummary {
|
|||
durationSeconds: number;
|
||||
mistakes: number;
|
||||
completedAt: string;
|
||||
results?: ArcadeRoundResult;
|
||||
results?: ArcadeRoundResult | CrosswordRoundResult;
|
||||
}
|
||||
|
||||
export interface ArcadePackSummary {
|
||||
|
|
@ -118,6 +242,8 @@ export interface ArcadePackSummary {
|
|||
schemaVersion: number;
|
||||
itemCount: number;
|
||||
defaultAllowedMistakes: number;
|
||||
defaultInstantCheck: boolean;
|
||||
defaultAllowHints: boolean;
|
||||
bestScore: number | null;
|
||||
latestAttempt: ArcadeAttemptSummary | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue