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

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

View file

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