Initial addition of the Arcade study feature. Add connections as the first working game.
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s

This commit is contained in:
Elijah 2026-07-13 17:35:18 -07:00
parent 7b90409f2e
commit 611a585757
43 changed files with 5990 additions and 68 deletions

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { NormalizedConnectionsPack } from "@/types/arcade";
const pack: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: "Test",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
id: `group-${groupIndex + 1}`,
category: `Category ${groupIndex + 1}`,
explanation: `Explanation ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => ({
id: `g${groupIndex + 1}-i${itemIndex + 1}`,
text: `Item ${groupIndex + 1}-${itemIndex + 1}`,
})),
})),
};
const settings = { allowedMistakes: 4, oneAwayFeedback: true };
describe("Connections engine", () => {
it("shuffles deterministically for a seed", () => {
const items = Array.from({ length: 16 }, (_, index) => index);
expect(deterministicShuffle(items, "round-1")).toEqual(deterministicShuffle(items, "round-1"));
expect(deterministicShuffle(items, "round-1")).not.toEqual(deterministicShuffle(items, "round-2"));
});
it("scores a perfect completed round", () => {
const submissions = pack.content.map((group, index) => ({
itemIds: group.items.map((item) => item.id),
elapsedMs: (index + 1) * 1000,
}));
const replay = replayConnectionsAttempt(pack, submissions, settings, 12);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "WON", score: 400, mistakes: 0, accuracy: 1 });
});
it("deducts mistakes and detects one-away selections", () => {
const wrong = [
...pack.content[0].items.slice(0, 3).map((item) => item.id),
pack.content[1].items[0].id,
];
const submissions = [
{ itemIds: wrong, elapsedMs: 1000 },
...pack.content.map((group, index) => ({ itemIds: group.items.map((item) => item.id), elapsedMs: (index + 2) * 1000 })),
];
const replay = replayConnectionsAttempt(pack, submissions, settings, 20);
expect(replay.result).toMatchObject({ outcome: "WON", score: 390, mistakes: 1, hintsUsed: 1, accuracy: 0.8 });
});
it("ends after the configured number of mistakes", () => {
const wrong = [pack.content[0].items[0].id, pack.content[1].items[0].id, pack.content[2].items[0].id, pack.content[3].items[0].id];
const replay = replayConnectionsAttempt(pack, Array.from({ length: 4 }, (_, index) => ({ itemIds: wrong, elapsedMs: index * 1000 })), settings, 10);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "LOST", score: 0, mistakes: 4 });
});
it("rejects selections containing unknown item ids", () => {
expect(() => replayConnectionsAttempt(pack, [{ itemIds: ["missing", "a", "b", "c"], elapsedMs: 0 }], settings, 0)).toThrow("invalid tile selection");
});
});

View file

@ -0,0 +1,110 @@
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsGroup,
NormalizedConnectionsPack,
} from "@/types/arcade";
function hashSeed(seed: string) {
let value = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
value ^= seed.charCodeAt(index);
value = Math.imul(value, 16777619);
}
return value >>> 0;
}
function seededRandom(seed: string) {
let state = hashSeed(seed);
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
export function deterministicShuffle<T>(items: readonly T[], seed: string): T[] {
const result = [...items];
const random = seededRandom(seed);
for (let index = result.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
}
return result;
}
function groupForSelection(groups: NormalizedConnectionsGroup[], itemIds: string[]) {
const selected = new Set(itemIds);
return groups.find(
(group) => group.items.length === selected.size && group.items.every((item) => selected.has(item.id))
);
}
export function isOneAway(
groups: NormalizedConnectionsGroup[],
selectedItemIds: string[],
solvedGroupIds: Set<string>
) {
const selected = new Set(selectedItemIds);
return groups.some(
(group) =>
!solvedGroupIds.has(group.id) &&
group.items.filter((item) => selected.has(item.id)).length === 3
);
}
export function replayConnectionsAttempt(
pack: NormalizedConnectionsPack,
submissions: ConnectionsSubmission[],
settings: ArcadeSessionSettings,
durationSeconds: number
): { complete: boolean; result: ArcadeRoundResult } {
const validItemIds = new Set(pack.content.flatMap((group) => group.items.map((item) => item.id)));
const solvedGroupIds = new Set<string>();
const incorrectSelections: string[][] = [];
let hintsUsed = 0;
let mistakes = 0;
let processed = 0;
for (const submission of submissions) {
if (solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes) break;
if (new Set(submission.itemIds).size !== 4 || submission.itemIds.some((id) => !validItemIds.has(id))) {
throw new Error("An attempt contains an invalid tile selection.");
}
processed += 1;
const group = groupForSelection(pack.content, submission.itemIds);
if (group && !solvedGroupIds.has(group.id)) {
solvedGroupIds.add(group.id);
} else {
if (settings.oneAwayFeedback && isOneAway(pack.content, submission.itemIds, solvedGroupIds)) {
hintsUsed += 1;
}
mistakes += 1;
incorrectSelections.push([...submission.itemIds]);
}
}
const complete = solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes;
const score = Math.max(0, solvedGroupIds.size * 100 - mistakes * 10);
const result: ArcadeRoundResult = {
outcome: solvedGroupIds.size === pack.content.length ? "WON" : "LOST",
score,
maxScore: 400,
accuracy: processed === 0 ? 0 : solvedGroupIds.size / processed,
durationSeconds,
mistakes,
hintsUsed,
groups: pack.content.map((group) => ({
groupId: group.id,
category: group.category,
items: group.items.map((item) => item.text),
explanation: group.explanation,
solved: solvedGroupIds.has(group.id),
})),
incorrectSelections,
};
return { complete, result };
}

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { ArcadeImportError, parseConnectionsImport, parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
function validPack() {
return {
schemaVersion: 1,
type: "connections",
name: "Medication Connections",
description: "Four medication groups",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
category: `Category ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => `Item ${groupIndex + 1}-${itemIndex + 1}`),
explanation: `Explanation ${groupIndex + 1}`,
})),
};
}
describe("Connections imports", () => {
it("normalizes a valid board and creates stable ids", () => {
const result = parseConnectionsImport(JSON.stringify(validPack()));
expect(result.preview.itemCount).toBe(16);
expect(result.preview.itemCount).toBe(16);
expect(result.preview.normalized.content[0].id).toBe("group-1");
expect(result.preview.normalized.content[0].items[0].id).toBe("group-1-item-1");
});
it("rejects fences and surrounding prose", () => {
expect(() => parseConnectionsImport(`\`\`\`json\n${JSON.stringify(validPack())}\n\`\`\``)).toThrow(ArcadeImportError);
expect(() => parseConnectionsImport(`Here is the pack: ${JSON.stringify(validPack())}`)).toThrow(ArcadeImportError);
});
it("rejects duplicate tiles case-insensitively", () => {
const pack = validPack();
pack.content[1].items[0] = " item 1-1 ";
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("Every tile must be unique");
});
it("rejects unknown keys", () => {
const pack = { ...validPack(), extra: true };
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("does not match schema version 1");
});
it("reports long-tile warnings without changing the content", () => {
const pack = validPack();
pack.content[0].items[0] = "This intentionally long tile contains too many words";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings.length).toBeGreaterThan(0);
expect(result.preview.normalized.content[0].items[0].text).toBe(pack.content[0].items[0]);
});
it("does not treat a shared generic word as a similar category", () => {
const pack = validPack();
pack.content[0].category = "ACE Inhibitors";
pack.content[1].category = "Proton Pump Inhibitors";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings).not.toContain(
"Categories “ACE Inhibitors” and “Proton Pump Inhibitors” may be too similar."
);
});
it("previews multiple packs from one JSON array", () => {
const first = validPack();
const second = { ...validPack(), name: "Second board" };
const result = parseConnectionsImportBatch(JSON.stringify([first, second]));
expect(result.count).toBe(2);
expect(result.packs.map((pack) => pack.name)).toEqual(["Medication Connections", "Second board"]);
});
it("limits batch imports to ten packs", () => {
const batch = Array.from({ length: 11 }, () => validPack());
expect(() => parseConnectionsImportBatch(JSON.stringify(batch))).toThrow("between 1 and 10");
});
});

View file

@ -0,0 +1,170 @@
import { parseAndRepairJson } from "@/lib/jsonRepair";
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeImportPreview,
ArcadeImportBatchPreview,
ArcadeValidationReport,
NormalizedConnectionsPack,
} from "@/types/arcade";
export class ArcadeImportError extends Error {
constructor(message: string, public readonly details: string[] = []) {
super(message);
}
}
function clean(value: string) {
return value.trim().replace(/\s+/g, " ");
}
function comparisonKey(value: string) {
return clean(value).toLocaleLowerCase();
}
function safeId(value: string, fallback: string) {
const id = value
.trim()
.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return id || fallback;
}
function findWarnings(pack: NormalizedConnectionsPack) {
const warnings: string[] = [];
const categoryKeys = pack.content.map((group) => comparisonKey(group.category));
for (const group of pack.content) {
for (const item of group.items) {
if (item.text.length > 40) {
warnings.push(`${item.text}” is longer than 40 characters and may be difficult to scan.`);
} else if (item.text.split(/\s+/).length > 4) {
warnings.push(`${item.text}” contains more than four words.`);
}
}
}
for (let left = 0; left < categoryKeys.length; left += 1) {
for (let right = left + 1; right < categoryKeys.length; right += 1) {
const a = categoryKeys[left];
const b = categoryKeys[right];
if (a.includes(b) || b.includes(a)) {
warnings.push(
`Categories “${pack.content[left].category}” and “${pack.content[right].category}” may be too similar.`
);
}
}
}
return [...new Set(warnings)];
}
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview {
const trimmed = rawJson.trim();
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
if (trimmed.includes("```") || (!isObject && !isArray)) {
throw new ArcadeImportError("Paste one raw JSON object or an array of pack objects without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const values = Array.isArray(repaired.data) ? repaired.data : [repaired.data];
if (values.length < 1 || values.length > 10) {
throw new ArcadeImportError("Import between 1 and 10 Connections packs at a time.");
}
const packs = values.map((value, index) => {
try {
const parsed = parseConnectionsImport(JSON.stringify(value));
return {
...parsed.preview,
wasRepaired: parsed.preview.wasRepaired || repaired.wasRepaired,
};
} catch (error) {
if (error instanceof ArcadeImportError) {
throw new ArcadeImportError(`Pack ${index + 1}: ${error.message}`, error.details);
}
throw error;
}
});
return { packs, count: packs.length, wasRepaired: repaired.wasRepaired };
}
export function parseConnectionsImport(rawJson: string): {
preview: ArcadeImportPreview;
report: ArcadeValidationReport;
} {
const trimmed = rawJson.trim();
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
throw new ArcadeImportError("Paste one raw JSON object without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const parsed = connectionsImportSchema.safeParse(repaired.data);
if (!parsed.success) {
throw new ArcadeImportError(
"The Connections pack does not match schema version 1.",
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
);
}
const categoryKeys = parsed.data.content.map((group) => comparisonKey(group.category));
if (new Set(categoryKeys).size !== categoryKeys.length) {
throw new ArcadeImportError("Every group must have a unique category name.");
}
const itemKeys = parsed.data.content.flatMap((group) => group.items.map(comparisonKey));
if (new Set(itemKeys).size !== itemKeys.length) {
throw new ArcadeImportError("Every tile must be unique across the entire board.");
}
const usedGroupIds = new Set<string>();
const normalized: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: clean(parsed.data.name),
description: parsed.data.description ? clean(parsed.data.description) : undefined,
settings: { ...parsed.data.settings },
content: parsed.data.content.map((group, groupIndex) => {
const fallbackId = `group-${groupIndex + 1}`;
const groupId = safeId(group.id ?? fallbackId, fallbackId);
if (usedGroupIds.has(groupId)) {
throw new ArcadeImportError(`Group id “${groupId}” is duplicated.`);
}
usedGroupIds.add(groupId);
return {
id: groupId,
category: clean(group.category),
explanation: group.explanation.trim(),
items: group.items.map((text, itemIndex) => ({
id: `${groupId}-item-${itemIndex + 1}`,
text: clean(text),
})),
};
}),
};
const warnings = findWarnings(normalized);
const report = { wasRepaired: repaired.wasRepaired, warnings };
return {
report,
preview: {
gameType: "connections",
name: normalized.name,
description: normalized.description,
categories: normalized.content.map((group) => group.category),
itemCount: 16,
wasRepaired: repaired.wasRepaired,
warnings,
normalized,
},
};
}

View file

@ -0,0 +1,35 @@
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import type { ArcadeGameKey } from "@/types/arcade";
const CONNECTIONS_PROMPT = `You are generating a Connections study game as strict JSON.
Return exactly one JSON object with no Markdown fence or commentary.
Use this schema:
{"schemaVersion":1,"type":"connections","name":"...","description":"...","settings":{"groupCount":4,"itemsPerGroup":4,"allowedMistakes":4},"content":[{"id":"group-1","category":"...","items":["...","...","...","..."],"explanation":"..."}]}
Rules:
- Generate exactly four groups of exactly four unique items.
- Every item must fit only its intended group within this board.
- Use specific, distinct category names and concise tiles of one to four words.
- Give every group a concise explanation.
- Do not create arbitrary leftover groups or use broad categories shared by most tiles.
- Base all content strictly on the study material below.
Material:
[paste your notes or lecture content here]`;
export const ARCADE_SERVER_REGISTRY = {
connections: {
schemaVersion: 1,
preview: parseConnectionsImportBatch,
defaultInstructions: CONNECTIONS_PROMPT,
},
} satisfies Record<ArcadeGameKey, {
schemaVersion: number;
preview: typeof parseConnectionsImportBatch;
defaultInstructions: string;
}>;
export function getArcadeGameDefinition(gameType: ArcadeGameKey) {
return ARCADE_SERVER_REGISTRY[gameType];
}

View file

@ -1,7 +1,7 @@
import { z } from "zod";
export const studyActivitySchema = z.object({
type: z.enum(["FLASHCARD", "QUIZ_QUESTION"]),
type: z.enum(["FLASHCARD", "QUIZ_QUESTION", "ARCADE_GROUP"]),
});
export type StudyActivityType = z.infer<typeof studyActivitySchema>["type"];

View file

@ -0,0 +1,61 @@
import { z } from "zod";
const connectionGroupSchema = z.object({
id: z.string().trim().min(1).optional(),
category: z.string().trim().min(1).max(100),
items: z.tuple([
z.string().trim().min(1),
z.string().trim().min(1),
z.string().trim().min(1),
z.string().trim().min(1),
]),
explanation: z.string().trim().min(1).max(1000),
// Accepted for compatibility with early packs, but no longer used or generated.
difficulty: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(),
}).strict();
export const connectionsImportSchema = z.object({
schemaVersion: z.literal(1),
type: z.literal("connections"),
name: z.string().trim().min(1).max(150),
description: z.string().trim().max(500).optional(),
settings: z.object({
groupCount: z.literal(4),
itemsPerGroup: z.literal(4),
allowedMistakes: z.number().int().min(1).max(8),
}).strict(),
content: z.array(connectionGroupSchema).length(4),
}).strict();
export const arcadePreviewRequestSchema = z.object({
gameType: z.literal("connections"),
rawJson: z.string().min(1),
});
export const arcadePackCreateSchema = arcadePreviewRequestSchema.extend({
classId: z.string().min(1),
name: z.string().trim().min(1).max(150).optional(),
names: z.array(z.string().trim().min(1).max(150)).min(1).max(10).optional(),
warningsAcknowledged: z.boolean().default(false),
});
export const arcadePackUpdateSchema = z.object({
name: z.string().trim().min(1).max(150),
});
export const arcadeAttemptCreateSchema = z.object({
mode: z.literal("CLASSIC").default("CLASSIC"),
durationSeconds: z.number().int().min(0).max(86400),
seed: z.string().min(1).max(100),
settings: z.object({
allowedMistakes: z.number().int().min(1).max(8),
oneAwayFeedback: z.boolean(),
}).strict(),
submissions: z.array(z.object({
itemIds: z.array(z.string().min(1)).length(4),
elapsedMs: z.number().int().min(0).max(86400000),
}).strict()).max(50),
});
export type ConnectionsImportInput = z.infer<typeof connectionsImportSchema>;
export type ArcadeAttemptCreateInput = z.infer<typeof arcadeAttemptCreateSchema>;