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
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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue