Initial crossword addition and arcade redesign
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s

This commit is contained in:
Elijah 2026-07-14 19:32:41 -07:00
parent 611a585757
commit 5bec95fb30
32 changed files with 1635 additions and 56 deletions

View file

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

View file

@ -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,
},
};
}

View 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");
});
});

View 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,
})),
],
};
}

View 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);
}
});
});

View 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);
}

View 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");
});
});

View 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 };
}

View 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}`,
})),
};
}

View file

@ -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;
}>;

View file

@ -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>;