// Board configuration
const DEFAULT_BOARD_SIZE = 8;
const MIN_BOARD_SIZE = 4;
const MAX_BOARD_SIZE = 13;
const EMPTY_CELL = 0;
const OCCUPIED_CELL = 1;
const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1";
const AppLanguage = window.AppLanguage;
// Mutable game and solver state
let mode = "queens";
let boardSize = DEFAULT_BOARD_SIZE;
let pendingBoardSize = DEFAULT_BOARD_SIZE;
let board = createEmptyBoard(boardSize);
let stepCount = 0;
let elapsedBeforeTimerStartMs = 0;
let timerStartedAtMs = Date.now();
let gameTimerId = null;
let isSolved = false;
let solverSteps = [];
let solverStepIndex = -1;
let solverTimerId = null;
let solverSpeedMs = 320;
let solverLoading = false;
let solverRequestId = 0;
let currentSolverHighlight = null;
let solverInitialBoard = createEmptyBoard(boardSize);
// Cached DOM references
const boardDiv = document.getElementById("board");
const statusMessage = document.getElementById("statusMessage");
const queenList = document.getElementById("queenList");
const pieceCountBadge = document.getElementById("pieceCountBadge");
const langSwitch = document.getElementById("langSwitch");
const gameModeEyebrow = document.getElementById("gameModeEyebrow");
const gameTitle = document.getElementById("gameTitle");
const boardModeText = document.getElementById("boardModeText");
const boardSizeInfoPrefix = document.getElementById("boardSizeInfoPrefix");
const pieceListTitle = document.getElementById("pieceListTitle");
const infoModalTitle = document.getElementById("infoModalTitle");
const infoModeRuleText = document.getElementById("infoModeRuleText");
const infoModalProblemHeading = document.getElementById("infoModalProblemHeading");
const infoModalProblemText = document.getElementById("infoModalProblemText");
const stepCounterValue = document.getElementById("stepCounterValue");
const timerValue = document.getElementById("timerValue");
const gameProgressState = document.getElementById("gameProgressState");
const solvedCelebration = document.getElementById("solvedCelebration");
const solvedCelebrationTitle = document.getElementById("solvedCelebrationTitle");
const solvedCelebrationText = document.getElementById("solvedCelebrationText");
const btnQueens = document.getElementById("btnQueens");
const btnRooks = document.getElementById("btnRooks");
const btnPrepareSolve = document.getElementById("btnPrepareSolve");
const btnInstantSolve = document.getElementById("btnInstantSolve");
const btnPrevStep = document.getElementById("btnPrevStep");
const btnNextStep = document.getElementById("btnNextStep");
const btnPlayPause = document.getElementById("btnPlayPause");
const boardNavigator = document.getElementById("boardNavigator");
const solverMessage = document.getElementById("solverMessage");
const solverStepInfo = document.getElementById("solverStepInfo");
const solverSpeedInput = document.getElementById("solverSpeedInput");
const solverSpeedValue = document.getElementById("solverSpeedValue");
const settingsSidebar = document.getElementById("settingsSidebar");
const settingsTrigger = document.getElementById("settingsTrigger");
const settingsTriggerArrow = document.getElementById("settingsTriggerArrow");
const settingsTriggerLabel = document.getElementById("settingsTriggerLabel");
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
const saveSidebar = document.getElementById("saveSidebar");
const saveBackdrop = document.getElementById("saveBackdrop");
const savePointsSidebar = document.getElementById("savePointsSidebar");
const savePointsBackdrop = document.getElementById("savePointsBackdrop");
const boardSizeInput = document.getElementById("boardSizeInput");
const boardSizeValue = document.getElementById("boardSizeValue");
const boardSizeValueMirror = document.getElementById("boardSizeValueMirror");
const sidebarBoardSizeValue = document.getElementById("sidebarBoardSizeValue");
const sidebarBoardSizeValueMirror = document.getElementById("sidebarBoardSizeValueMirror");
const savePointsList = document.getElementById("savePointsList");
const saveNameInput = document.getElementById("saveNameInput");
const saveNoteInput = document.getElementById("saveNoteInput");
const saveFavoriteInput = document.getElementById("saveFavoriteInput");
const savePointModeFilter = document.getElementById("savePointModeFilter");
const savePointSizeFilter = document.getElementById("savePointSizeFilter");
const savePointSortFilter = document.getElementById("savePointSortFilter");
const savePointFavoritesOnly = document.getElementById("savePointFavoritesOnly");
const timeHistoryTableBody = document.getElementById("timeHistoryTableBody");
const infoModal = document.getElementById("infoModal");
const infoModalBackdrop = document.getElementById("infoModalBackdrop");
// -----------------------------------------------------------------------------
// Translation dictionaries and language-specific copy
let currentLanguage = "de";
const MODE_COPY = {
de: {
queens: {
singular: "Dame",
plural: "Damen",
eyebrow: "Damen-Modus",
gameTitle: "Damen-Spielbrett",
boardText: "Platziere Damen frei auf dem Brett. Konflikte entstehen in derselben Reihe, Spalte oder Diagonale.",
infoTitle: "Spielanleitung",
infoRuleText: "In diesem Modus spielst du mit Damen. Eine Dame ist die Figur mit dem Symbol ♛. Sie darf keine andere Dame in derselben waagerechten Reihe, senkrechten Spalte oder schrägen Diagonale treffen. Mit einem Klick setzt du eine Dame auf ein freies Feld oder entfernst sie wieder.",
infoProblemHeading: "Was ist eine Dame?",
infoProblemText: "Eine Dame ist eine Schachfigur. In diesem Spiel musst du keine Schachregeln lernen, sondern nur wissen: Eine Dame kann gerade und schräg über das Brett wirken. Darum müssen Damen genug Abstand haben, damit sie sich nicht gegenseitig erreichen."
},
rooks: {
singular: "Turm",
plural: "Türme",
eyebrow: "Türme-Modus",
gameTitle: "Türme-Spielbrett",
boardText: "Platziere Türme frei auf dem Brett. Konflikte entstehen in derselben Reihe oder Spalte.",
infoTitle: "Spielanleitung",
infoRuleText: "In diesem Modus spielst du mit Türmen. Ein Turm ist die Figur mit dem Symbol ♜. Er darf keinen anderen Turm in derselben waagerechten Reihe oder senkrechten Spalte treffen. Schräge Diagonalen sind hier egal. Mit einem Klick setzt du einen Turm auf ein freies Feld oder entfernst ihn wieder.",
infoProblemHeading: "Was ist ein Turm?",
infoProblemText: "Ein Turm ist eine Schachfigur. Für dieses Spiel reicht eine einfache Regel: Ein Turm wirkt nur gerade nach oben, unten, links und rechts. Darum dürfen zwei Türme nicht in derselben Reihe oder Spalte stehen."
}
},
en: {
queens: {
singular: "Queen",
plural: "Queens",
eyebrow: "Queens Mode",
gameTitle: "Queens Board",
boardText: "Place queens freely on the board. Conflicts happen in the same row, column, or diagonal.",
infoTitle: "How To Play",
infoRuleText: "In this mode you play with queens. A queen is the piece shown with the symbol ♛. It must not reach another queen in the same horizontal row, vertical column, or diagonal line. Click once to place a queen on an empty square or remove it again.",
infoProblemHeading: "What is a queen?",
infoProblemText: "A queen is a chess piece. You do not need full chess knowledge here. You only need to know that a queen can affect the board in straight and diagonal lines. That is why queens need enough distance so they cannot reach each other."
},
rooks: {
singular: "Rook",
plural: "Rooks",
eyebrow: "Rooks Mode",
gameTitle: "Rooks Board",
boardText: "Place rooks freely on the board. Conflicts happen in the same row or column.",
infoTitle: "How To Play",
infoRuleText: "In this mode you play with rooks. A rook is the piece shown with the symbol ♜. It must not reach another rook in the same horizontal row or vertical column. Diagonal lines do not matter here. Click once to place a rook on an empty square or remove it again.",
infoProblemHeading: "What is a rook?",
infoProblemText: "A rook is a chess piece. For this game you only need one simple idea: a rook affects the board only in straight lines up, down, left, and right. That is why two rooks must not stand in the same row or column."
}
}
};
const translations = {
de: {
pageTitle: "Dashboard",
navBrand: "8-Damen- und 8-Türme Solver",
navDashboard: "Dashboard",
navLogout: "Logout",
accountLabel: "Account",
accountShortcutAria: "Konto und Login-Einstellungen öffnen",
introKicker: "Puzzle-Modus",
introTitle: "Wähl deinen Modus und löse das Brett auf deine Art.",
introText: "Wechsle zwischen Damen und Türmen, nutze Animation oder Sofort-Lösung und öffne die Spiel-Einstellungen jederzeit ueber den linken Seiten-Tab.",
modeSwitchAria: "Spielmodus",
btnQueens: "Damen",
btnRooks: "Türme",
infoButton: "Spielanleitung",
gameStatsAria: "Spielstatus",
stepsLabel: "Schritte",
timeLabel: "Zeit",
statusLabel: "Status",
progressRunning: "Läuft",
progressSolved: "Gelöst",
boardSizePrefix: ({ modeLabel }) => `Aktuelle Brettgrösse im ${modeLabel}-Modus:`,
solverKicker: "Solver",
solverTitle: "Lösungswerkzeuge",
animationSolverTitle: "Animations-Solver",
animationSolverText: "Bereite den Lösungsweg vor, gehe ihn manuell Schritt für Schritt durch oder spiele die Animation automatisch ab.",
instantSolverTitle: "Sofort-Lösung",
instantSolverText: "Lade direkt eine gültige Lösung, ohne Zwischenschritte oder Animation.",
defaultSolverMessage: "Lade eine Animation und steuere sie am Brett oder spiele sie automatisch ab.",
solverReady: ({ modeLabel }) => `Solver für ${modeLabel} bereit. ${t("defaultSolverMessage")}`,
solverSpeedLabel: "Animationsgeschwindigkeit",
btnPrepareSolve: "Animation laden",
btnPrepareSolveLoading: "Animation wird geladen...",
btnPrevStep: "Zurück",
btnNextStep: "Vorwärts",
btnPlayPause: "Animation abspielen",
btnPlayReplay: "Animation erneut abspielen",
btnPlayPauseRunning: "Animation pausieren",
btnInstantSolve: "Sofort lösen",
btnReset: "Brett zurücksetzen",
settingsTrigger: "Spiel-Einstellungen",
settingsSidebarTitle: "Spiel-Einstellungen",
settingsSidebarClose: "Schliessen",
boardSettingsKicker: "Brett",
boardSettingsTitle: "Brettgrösse",
settingsSidebarText: "Passe die Brettgroesse zwischen 4 und 13 an.",
boardSizeInputLabel: "Grösse",
applyBoardSizeButton: "Grösse anwenden",
settingsSidebarHint: "Beim ändern wird das Brett zur Sicherheit zurückgesetzt. Auf dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.",
saveSettingsKicker: "Speichern",
saveSettingsTitle: "Fortschritt speichern",
saveSettingsText: "Öffne hier die Optionen zum Speichern oder Laden deiner Save-Points.",
solutionSettingsKicker: "Lösung",
solutionSettingsTitle: "Lösungswerkzeuge",
solutionSettingsText: "Lade eine Animation, steuere sie am Brett mit Vorwärts und Zurück oder spiele sie automatisch ab.",
btnSave: "Speichern",
btnLoad: "Laden",
saveSidebarTitle: "Speicherpunkt",
saveSidebarClose: "Schliessen",
saveSidebarText: "Gib dem Spielstand einen klaren Namen und optional eine kurze Notiz.",
saveNameLabel: "Name",
saveNamePlaceholder: "z. B. Fast gelöste Dame 8x8",
saveNoteLabel: "Notiz",
saveNotePlaceholder: "z. B. Gute Startposition fuer Türmetest",
saveFavoriteLabel: "Als Favorit markieren",
saveNowButton: "Jetzt speichern",
savePointsSidebarTitle: "Save-Points",
savePointsSidebarClose: "Schliessen",
savePointsSidebarText: "Wähle einen Speicherstand aus der Liste.",
savePointModeFilterLabel: "Modus",
savePointSizeFilterLabel: "Brettgrösse",
savePointSortFilterLabel: "Sortierung",
savePointFavoritesOnlyLabel: "Nur Favoriten anzeigen",
savePointsEmptyState: "Noch keine Speicherstände gefunden.",
filterAll: "Alle",
filterNewest: "Neueste zuerst",
filterOldest: "älteste zuerst",
filterUpdated: "Zuletzt bearbeitet",
filterFavorites: "Favoriten zuerst",
historyKicker: "Save-Points",
historyTitle: "Gespeicherte Zeiten und Fortschritte",
historySubtitle: "Die Tabelle zeigt gespeicherte Save-Points mit Zeit, Schritten und Status.",
btnRefreshHistory: "Aktualisieren",
historyHeadSave: "Save-Point",
historyHeadMode: "Modus",
historyHeadBoard: "Brett",
historyHeadSteps: "Schritte",
historyHeadTime: "Zeit",
historyHeadStatus: "Status",
historyHeadSaved: "Gespeichert",
infoModalClose: "Schliessen",
infoModalIntro: "Das Schachbrett besteht aus vielen quadratischen Feldern. Jedes Feld kann leer sein oder genau eine Figur enthalten. Deine Aufgabe ist es, Figuren auf das Brett zu setzen, ohne dass sie sich gegenseitig angreifen. Das Puzzle ist erst gelöst, wenn genau so viele Figuren auf dem Brett stehen wie das Brett Felder pro Seite hat, also zum Beispiel 8 Figuren auf einem 8x8-Brett.",
infoModalModeHeading: "Grundregeln",
infoModalTimeHeading: "Was zeigen Zeit und Schritte?",
infoModalTimeText: "Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das aktuelle Spiel weiter, auch wenn du die Seite neu lädst. Beim Speichern werden Zeit und Schritte zusammen mit dem Save-Point abgelegt.",
pieceListTitle: ({ modeLabel }) => `Positionen der ${modeLabel}`,
historyEmpty: "Noch keine gespeicherten Zeiten vorhanden.",
placedWord: "gesetzt",
noSavePointsFiltered: "Keine Speicherstände fuer diese Filter gefunden.",
previewEmpty: "Noch keine Figuren gesetzt",
createdLabel: "Erstellt",
updatedLabel: "Aktualisiert",
previewLabel: "Vorschau",
noteLabel: "Notiz",
noNote: "Keine Notiz",
stepsMeta: "Schritte",
timeMeta: "Zeit",
solvedMeta: "Gelöst",
openMeta: "Offen",
loadButton: "Laden",
favoriteAddButton: "Als Favorit",
favoriteRemoveButton: "Favorit entfernen",
deleteButton: "Löschen",
celebrationTitle: "Richtig gelöst!",
celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`,
settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`,
modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`,
solverDiscardedManual: "Solver-Ablauf verworfen, weil das Brett manuell geändert wurde.",
solverDiscardedStatus: "Solver-Ablauf verworfen. Bereite ihn bei Bedarf neu vor.",
solvedPerfect: ({ duration }) => `Perfekt gelöst in ${duration}.`,
animationReset: "Animations-Ablauf zurückgesetzt. Nutze die Schritt-Buttons am Brett oder starte die Animation erneut.",
animationAtStart: "Anfang des Animations-Ablaufs erreicht.",
animationPreparing: ({ modeLabel, size }) => `Lade den Animations-Ablauf für ${modeLabel.toLowerCase()} auf ${size} x ${size}...`,
animationPreparingStatus: ({ size }) => `Animations-Solver berechnet die Schritte fuer ${size} x ${size}.`,
noSolution: "Keine Lösung gefunden.",
noSolutionStatus: "Keine Lösung fuer dieses Brett gefunden.",
animationReady: ({ steps, queensMode }) => queensMode
? `Animations-Ablauf bereit mit ${steps} Schritten. Nutze die Vor- und Zurück-Buttons am Brett oder spiele die Animation ab, um das Backtracking zu verfolgen.`
: `Animations-Ablauf bereit mit ${steps} Schritten. Nutze die Vor- und Zurück-Buttons am Brett oder spiele die Animation ab.`,
animationReadyStatus: "Animations-Solver bereit. Nutze die Schritt-Buttons am Brett oder spiele die Animation ab.",
animationLoadError: "Der Solver-Ablauf konnte nicht geladen werden.",
animationFinishedStatus: ({ size }) => `Loesung für ${size} x ${size} Schritt fuer Schritt aufgebaut.`,
animationAlreadyFinished: "Der Animations-Ablauf ist bereits abgeschlossen.",
animationPaused: "Animations-Solver pausiert.",
animationRunning: "Animations-Solver läuft.",
instantLoading: ({ modeLabel, size }) => `Lade die Sofort-Lösung für ${modeLabel.toLowerCase()} auf ${size} x ${size}...`,
instantLoadingStatus: "Sofort-Lösung wird geladen.",
instantLoaded: "Sofort-Lösung geladen. Starte den Animations-Solver, wenn du den Lösungsweg sehen möchtest.",
instantLoadedStatus: ({ size }) => `Sofort-Loesung für ${size} x ${size} geladen.`,
instantLoadError: "Die Sofort-Lösung konnte nicht geladen werden.",
saveError: "Der Speicherstand konnte nicht gespeichert werden.",
saveSuccess: ({ name }) => `Speicherpunkt "${name}" gespeichert.`,
loadError: "Dieser Speicherstand konnte nicht geladen werden.",
loadSuccess: ({ name }) => `Speicherpunkt "${name}" geladen.`,
favoriteError: "Favorit konnte nicht aktualisiert werden.",
favoriteSuccess: "Favoritenstatus aktualisiert.",
deleteConfirm: "Diesen Speicherpunkt wirklich löschen?",
deleteError: "Speicherpunkt konnte nicht gelöscht werden.",
deleteSuccess: "Speicherpunkt geloescht.",
pieceCount: ({ count }) => `${count} gesetzt`,
pieceEmpty: ({ modeLabel }) => `Noch keine ${modeLabel.toLowerCase()} gesetzt.`,
saveDefaultName: ({ piece, size, count }) => `${piece} ${size}x${size} - ${count} gesetzt`,
filterQueens: "Damen",
filterRooks: "Türme",
solverStepInfo: ({ currentStep, totalSteps }) => `Schritt ${currentStep} / ${totalSteps}`,
speedValue: ({ speed }) => `${speed} ms`,
languageSwitchAria: "Sprache wechseln",
footerText: "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.",
footerPrivacy: "Datenschutzerklärung",
footerImprint: "Impressum",
footerNavAria: "Rechtliche Hinweise"
},
en: {
pageTitle: "Dashboard",
navBrand: "8 Queens and 8 Rooks Solver",
navDashboard: "Dashboard",
navLogout: "Logout",
accountLabel: "Account",
accountShortcutAria: "Open account and login settings",
introKicker: "Puzzle Mode",
introTitle: "Choose your mode and solve the board your way.",
introText: "Switch between queens and rooks, use animation or instant solve, and open game settings anytime from the left side tab.",
modeSwitchAria: "Game mode",
btnQueens: "Queens",
btnRooks: "Rooks",
infoButton: "How to play?",
gameStatsAria: "Game status",
stepsLabel: "Steps",
timeLabel: "Time",
statusLabel: "Status",
progressRunning: "Running",
progressSolved: "Solved",
boardSizePrefix: ({ modeLabel }) => `Current board size in ${modeLabel} mode:`,
solverKicker: "Solver",
solverTitle: "Solving Tools",
animationSolverTitle: "Animation Solver",
animationSolverText: "Prepare the solving path, step through it manually, or play the animation automatically.",
instantSolverTitle: "Instant Solve",
instantSolverText: "Load a valid solution directly, without intermediate steps or animation.",
defaultSolverMessage: "Load an animation and control it from the board or play it automatically.",
solverReady: ({ modeLabel }) => `Solver for ${modeLabel} is ready. ${t("defaultSolverMessage")}`,
solverSpeedLabel: "Animation speed",
btnPrepareSolve: "Load animation",
btnPrepareSolveLoading: "Loading animation...",
btnPrevStep: "Back",
btnNextStep: "Forward",
btnPlayPause: "Play animation",
btnPlayReplay: "Replay animation",
btnPlayPauseRunning: "Pause animation",
btnInstantSolve: "Solve instantly",
btnReset: "Reset board",
settingsTrigger: "Game settings",
settingsSidebarTitle: "Game settings",
settingsSidebarClose: "Close",
boardSettingsKicker: "Board",
boardSettingsTitle: "Board size",
settingsSidebarText: "Adjust the board size between 4 and 13.",
boardSizeInputLabel: "Size",
applyBoardSizeButton: "Apply size",
settingsSidebarHint: "Changing the size resets the board for safety. On phones, 8x8 is the intended maximum for a reliable fit and larger boards may appear cropped.",
saveSettingsKicker: "Saving",
saveSettingsTitle: "Save progress",
saveSettingsText: "Open the options for saving your progress or loading save points here.",
solutionSettingsKicker: "Solution",
solutionSettingsTitle: "Solving tools",
solutionSettingsText: "Load an animation, control it from the board with back and forward, or play it automatically.",
btnSave: "Save",
btnLoad: "Save points",
saveSidebarTitle: "Save Point",
saveSidebarClose: "Close",
saveSidebarText: "Give the current run a clear name and optionally a short note.",
saveNameLabel: "Name",
saveNamePlaceholder: "e.g. Nearly solved queen 8x8",
saveNoteLabel: "Note",
saveNotePlaceholder: "e.g. Strong starting position for rook test",
saveFavoriteLabel: "Mark as favorite",
saveNowButton: "Save now",
savePointsSidebarTitle: "Save Points",
savePointsSidebarClose: "Close",
savePointsSidebarText: "Choose a saved state from the list.",
savePointModeFilterLabel: "Mode",
savePointSizeFilterLabel: "Board size",
savePointSortFilterLabel: "Sorting",
savePointFavoritesOnlyLabel: "Show favorites only",
savePointsEmptyState: "No saved states yet.",
filterAll: "All",
filterNewest: "Newest first",
filterOldest: "Oldest first",
filterUpdated: "Recently updated",
filterFavorites: "Favorites first",
historyKicker: "Save Points",
historyTitle: "Saved Times and Progress",
historySubtitle: "The table shows saved runs with time, steps, and status.",
btnRefreshHistory: "Refresh",
historyHeadSave: "Save Point",
historyHeadMode: "Mode",
historyHeadBoard: "Board",
historyHeadSteps: "Steps",
historyHeadTime: "Time",
historyHeadStatus: "Status",
historyHeadSaved: "Saved",
infoModalClose: "Close",
infoModalIntro: "The chessboard is made of many square fields. Each field can either stay empty or hold exactly one piece. Your task is to place pieces on the board without letting them attack each other. The puzzle is only solved when the board contains exactly as many pieces as it has squares on one side, for example 8 pieces on an 8x8 board.",
infoModalModeHeading: "Basic rules",
infoModalTimeHeading: "What do time and steps show?",
infoModalTimeText: "The step counter records your moves on the board. Time keeps running for the current game even if you reload the page. When saving, time and steps are stored together with the save point.",
pieceListTitle: ({ modeLabel }) => `${modeLabel} positions`,
historyEmpty: "No saved times yet.",
placedWord: "placed",
noSavePointsFiltered: "No saved states found for these filters.",
previewEmpty: "No pieces placed yet",
createdLabel: "Created",
updatedLabel: "Updated",
previewLabel: "Preview",
noteLabel: "Note",
noNote: "No note",
stepsMeta: "Steps",
timeMeta: "Time",
solvedMeta: "Solved",
openMeta: "Open",
loadButton: "Load",
favoriteAddButton: "Favorite",
favoriteRemoveButton: "Remove favorite",
deleteButton: "Delete",
celebrationTitle: "Correctly solved!",
celebrationText: ({ modeLabel, duration }) => `Well done. Your ${modeLabel.toLowerCase()} solution is conflict-free in ${duration}.`,
settingsApplied: ({ size }) => `Board size set to ${size} x ${size}.`,
modeActivated: ({ piece }) => `${piece} mode activated.`,
solverDiscardedManual: "Solver trace discarded because the board was changed manually.",
solverDiscardedStatus: "Solver trace discarded. Prepare it again if needed.",
solvedPerfect: ({ duration }) => `Perfectly solved in ${duration}.`,
animationReset: "Animation trace reset. Use the step buttons on the board or start the animation again.",
animationAtStart: "Reached the start of the animation trace.",
animationPreparing: ({ modeLabel, size }) => `Loading the animation trace for ${modeLabel.toLowerCase()} on ${size} x ${size}...`,
animationPreparingStatus: ({ size }) => `Animation solver is preparing the steps for ${size} x ${size}.`,
noSolution: "No solution found.",
noSolutionStatus: "No solution found for this board.",
animationReady: ({ steps, queensMode }) => queensMode
? `Animation trace ready with ${steps} steps. Use the back and forward buttons on the board or play the animation to follow the backtracking process.`
: `Animation trace ready with ${steps} steps. Use the back and forward buttons on the board or play the animation.`,
animationReadyStatus: "Animation solver ready. Use the step buttons on the board or play the animation.",
animationLoadError: "The solver trace could not be loaded.",
animationFinishedStatus: ({ size }) => `Solution for ${size} x ${size} was built step by step.`,
animationAlreadyFinished: "The animation trace is already complete.",
animationPaused: "Animation solver paused.",
animationRunning: "Animation solver is running.",
instantLoading: ({ modeLabel, size }) => `Loading the instant solution for ${modeLabel.toLowerCase()} on ${size} x ${size}...`,
instantLoadingStatus: "Instant solution is loading.",
instantLoaded: "Instant solution loaded. Start the animation solver if you want to see the solving path.",
instantLoadedStatus: ({ size }) => `Instant solution for ${size} x ${size} loaded.`,
instantLoadError: "The instant solution could not be loaded.",
saveError: "The save point could not be saved.",
saveSuccess: ({ name }) => `Save point "${name}" saved.`,
loadError: "This save point could not be loaded.",
loadSuccess: ({ name }) => `Save point "${name}" loaded.`,
favoriteError: "Favorite status could not be updated.",
favoriteSuccess: "Favorite status updated.",
deleteConfirm: "Really delete this save point?",
deleteError: "Save point could not be deleted.",
deleteSuccess: "Save point deleted.",
pieceCount: ({ count }) => `${count} placed`,
pieceEmpty: ({ modeLabel }) => `No ${modeLabel.toLowerCase()} placed yet.`,
saveDefaultName: ({ piece, size, count }) => `${piece} ${size}x${size} - ${count} placed`,
filterQueens: "Queens",
filterRooks: "Rooks",
solverStepInfo: ({ currentStep, totalSteps }) => `Step ${currentStep} / ${totalSteps}`,
speedValue: ({ speed }) => `${speed} ms`,
languageSwitchAria: "Switch language",
footerText: "School project about the Eight Queens and Eight Rooks problem.",
footerPrivacy: "Privacy Policy",
footerImprint: "Imprint",
footerNavAria: "Legal information"
}
};
// -----------------------------------------------------------------------------
// Translation helpers
function t(key, params = {}) {
const value = translations[currentLanguage][key];
return typeof value === "function" ? value(params) : value;
}
function getDefaultSolverMessage() {
return t("defaultSolverMessage");
}
function getModeCopy(selectedMode = mode, language = currentLanguage) {
return MODE_COPY[language][selectedMode === "rooks" ? "rooks" : "queens"];
}
function getPieceLabel(selectedMode) {
return getModeCopy(selectedMode).singular;
}
function getModeLabel(selectedMode) {
return getModeCopy(selectedMode).plural;
}
function applyTextTranslations() {
AppLanguage.applyTranslations({
translate: (key) => t(key),
allowHtml: (element, translatedText) => element.tagName === "TITLE" || String(translatedText).includes("
Array(size).fill(EMPTY_CELL));
}
function clampBoardSize(size) {
return Math.min(MAX_BOARD_SIZE, Math.max(MIN_BOARD_SIZE, Number(size)));
}
function getColumnLabel(index) {
return String.fromCharCode(65 + index);
}
function cloneBoard(selectedBoard) {
return selectedBoard.map((row) => row.slice());
}
function getCellKey(row, col) {
return `${row}:${col}`;
}
function sanitizeBoard(candidateBoard, size) {
if (!Array.isArray(candidateBoard) || candidateBoard.length !== size) {
return createEmptyBoard(size);
}
if (candidateBoard.every((row) => Array.isArray(row) && row.length === size)) {
return candidateBoard.map((row) => row.map((value) => {
if (value === true) {
return OCCUPIED_CELL;
}
const numericValue = Number(value);
return numericValue === OCCUPIED_CELL ? OCCUPIED_CELL : EMPTY_CELL;
}));
}
const normalizedBoard = createEmptyBoard(size);
candidateBoard.forEach((value, row) => {
const numericValue = Number(value);
if (Number.isInteger(numericValue) && numericValue >= 0 && numericValue < size) {
normalizedBoard[row][numericValue] = OCCUPIED_CELL;
}
});
return normalizedBoard;
}
function getOccupiedPositions(selectedBoard = board) {
const positions = [];
for (let row = 0; row < selectedBoard.length; row++) {
for (let col = 0; col < selectedBoard[row].length; col++) {
if (selectedBoard[row][col] === OCCUPIED_CELL) {
positions.push({ row, col });
}
}
}
return positions;
}
function extractSolverBoard(payload, size) {
if (!Array.isArray(payload)) {
return createEmptyBoard(size);
}
if (payload.length === size) {
return sanitizeBoard(payload, size);
}
if (payload.length > 0) {
return sanitizeBoard(payload[0], size);
}
return createEmptyBoard(size);
}
function formatDuration(totalSeconds) {
const safeSeconds = Math.max(0, Number(totalSeconds) || 0);
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const seconds = safeSeconds % 60;
return [hours, minutes, seconds]
.map((value) => String(value).padStart(2, "0"))
.join(":");
}
function getElapsedMilliseconds() {
if (isSolved) {
return elapsedBeforeTimerStartMs;
}
return elapsedBeforeTimerStartMs + Math.max(0, Date.now() - timerStartedAtMs);
}
// Keep a minimal local snapshot so a refresh can restore the current run.
function persistCurrentSession() {
try {
window.localStorage.setItem(
GAME_STORAGE_KEY,
JSON.stringify({
mode,
boardSize,
board,
stepCount,
elapsedMs: getElapsedMilliseconds(),
isSolved
})
);
} catch (error) {
// Ignore unavailable storage so the game keeps working normally.
}
}
// -----------------------------------------------------------------------------
// Timer and UI synchronization
function updateProgressDisplay() {
stepCounterValue.textContent = String(stepCount);
timerValue.textContent = formatDuration(Math.floor(getElapsedMilliseconds() / 1000));
gameProgressState.textContent = isSolved ? t("progressSolved") : t("progressRunning");
gameProgressState.style.color = isSolved ? "#15803d" : "#1d4ed8";
updateSolvedCelebration();
}
function stopGameTimer() {
if (gameTimerId !== null) {
window.clearInterval(gameTimerId);
gameTimerId = null;
}
}
function startGameTimer() {
stopGameTimer();
if (isSolved) {
updateProgressDisplay();
persistCurrentSession();
return;
}
timerStartedAtMs = Date.now();
gameTimerId = window.setInterval(() => {
updateProgressDisplay();
persistCurrentSession();
}, 1000);
updateProgressDisplay();
persistCurrentSession();
}
function resetProgress({ stepCountValue = 0, elapsedMs = 0, solved = false } = {}) {
stepCount = Math.max(0, Number(stepCountValue) || 0);
elapsedBeforeTimerStartMs = Math.max(0, Number(elapsedMs) || 0);
isSolved = Boolean(solved);
timerStartedAtMs = Date.now();
if (isSolved) {
stopGameTimer();
updateProgressDisplay();
persistCurrentSession();
return;
}
startGameTimer();
}
function ensureFreshRunAfterSolvedBoard() {
if (!isSolved) {
return;
}
resetProgress();
}
// These helpers keep the static labels and the dynamic dashboard widgets aligned
// with the currently selected mode, language, and board size.
function updateModeButtons() {
btnQueens.classList.toggle("active", mode === "queens");
btnRooks.classList.toggle("active", mode === "rooks");
btnQueens.classList.toggle("secondary", mode !== "queens");
btnRooks.classList.toggle("secondary", mode !== "rooks");
}
function updateModeContent() {
const copy = getModeCopy(mode);
gameModeEyebrow.textContent = copy.eyebrow;
gameTitle.textContent = copy.gameTitle;
boardModeText.textContent = copy.boardText;
pieceListTitle.textContent = t("pieceListTitle", { modeLabel: copy.plural });
infoModalTitle.textContent = copy.infoTitle;
infoModeRuleText.textContent = copy.infoRuleText;
infoModalProblemHeading.textContent = copy.infoProblemHeading;
infoModalProblemText.textContent = copy.infoProblemText;
boardSizeInfoPrefix.textContent = t("boardSizePrefix", { modeLabel: copy.plural });
}
function updateSolvedCelebration() {
if (!isSolved) {
solvedCelebration.hidden = true;
return;
}
solvedCelebrationTitle.textContent = t("celebrationTitle");
solvedCelebrationText.textContent = t("celebrationText", {
modeLabel: getModeLabel(mode),
duration: formatDuration(Math.floor(getElapsedMilliseconds() / 1000))
});
solvedCelebration.hidden = false;
}
function updateSettingsTrigger(isOpen = settingsSidebar.classList.contains("open")) {
settingsTrigger.setAttribute("aria-expanded", String(isOpen));
settingsTriggerArrow.textContent = isOpen ? "←" : "→";
settingsTriggerLabel.textContent = t("settingsTrigger");
}
function updateBoardSizeDisplays(size) {
boardSizeValue.textContent = size;
boardSizeValueMirror.textContent = size;
sidebarBoardSizeValue.textContent = size;
sidebarBoardSizeValueMirror.textContent = size;
boardSizeInput.value = size;
}
function setStatus(message = "", color = "") {
statusMessage.textContent = message;
statusMessage.style.color = color;
}
function setSolverMessage(message = getDefaultSolverMessage()) {
solverMessage.textContent = message;
}
// -----------------------------------------------------------------------------
// Rendering and solver state helpers
function updateSolverSpeed(rawValue) {
solverSpeedMs = Number(rawValue);
solverSpeedInput.value = String(solverSpeedMs);
solverSpeedValue.textContent = t("speedValue", { speed: solverSpeedMs });
if (isSolverPlaying()) {
stopSolverPlayback(false);
scheduleNextSolverPlaybackStep();
}
}
// These render helpers are reused by manual play, solver playback,
// save-point loading, and session restore.
function updateBoardLayout() {
let cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize)));
let labelSize = cellSize;
if (window.innerWidth <= 760) {
const labelRatio = 0.72;
const availableWidth = Math.max(250, window.innerWidth - 26);
cellSize = Math.max(24, Math.floor(availableWidth / (boardSize + labelRatio)));
labelSize = Math.max(18, Math.floor(cellSize * labelRatio));
}
boardDiv.style.setProperty("--board-size", boardSize);
boardDiv.style.setProperty("--cell-size", `${cellSize}px`);
boardDiv.style.setProperty("--label-size", `${labelSize}px`);
}
function updateQueenList() {
queenList.innerHTML = "";
const positions = getOccupiedPositions();
positions.forEach(({ row, col }, index) => {
const position = `${getColumnLabel(col)}${boardSize - row}`;
const li = document.createElement("li");
li.textContent = `${getPieceLabel(mode)} ${index + 1}: ${position}`;
queenList.appendChild(li);
});
const placedPieces = positions.length;
pieceCountBadge.textContent = t("pieceCount", { count: placedPieces });
if (placedPieces === 0) {
const emptyItem = document.createElement("li");
emptyItem.className = "empty";
emptyItem.textContent = t("pieceEmpty", { modeLabel: getModeLabel(mode) });
queenList.appendChild(emptyItem);
}
}
function isSolverPlaying() {
return solverTimerId !== null;
}
function hasPreparedSolverTrace() {
return solverSteps.length > 0;
}
function hasCompletedSolverTrace() {
return hasPreparedSolverTrace() && solverStepIndex >= solverSteps.length - 1;
}
function stopSolverPlayback(refreshControls = true) {
if (solverTimerId !== null) {
window.clearTimeout(solverTimerId);
solverTimerId = null;
}
if (refreshControls) {
updateSolverControls();
}
}
function updateSolverControls() {
const currentStep = Math.max(0, solverStepIndex + 1);
const hasTrace = hasPreparedSolverTrace();
btnPrepareSolve.disabled = solverLoading;
btnInstantSolve.disabled = solverLoading;
btnPrevStep.disabled = solverLoading || !hasTrace || solverStepIndex < 0;
btnNextStep.disabled = solverLoading || !hasTrace || hasCompletedSolverTrace();
btnPlayPause.disabled = solverLoading;
boardNavigator.hidden = !hasTrace;
btnPrepareSolve.textContent = solverLoading ? t("btnPrepareSolveLoading") : t("btnPrepareSolve");
if (isSolverPlaying()) {
btnPlayPause.textContent = t("btnPlayPauseRunning");
} else if (hasCompletedSolverTrace()) {
btnPlayPause.textContent = t("btnPlayReplay");
} else {
btnPlayPause.textContent = t("btnPlayPause");
}
solverStepInfo.textContent = t("solverStepInfo", {
currentStep,
totalSteps: solverSteps.length
});
}
function discardSolverTrace(message = getDefaultSolverMessage()) {
solverRequestId += 1;
solverLoading = false;
stopSolverPlayback(false);
solverSteps = [];
solverStepIndex = -1;
currentSolverHighlight = null;
solverInitialBoard = createEmptyBoard(boardSize);
setSolverMessage(message);
updateSolverControls();
}
function syncProgressAfterBoardStateChange() {
if (isBoardSolved()) {
finishSolvedRun();
return;
}
if (isSolved) {
resetProgress({
stepCountValue: stepCount,
elapsedMs: getElapsedMilliseconds(),
solved: false
});
return;
}
updateProgressDisplay();
persistCurrentSession();
}
function rewindSolverTrace() {
stopSolverPlayback(false);
solverStepIndex = -1;
currentSolverHighlight = null;
board = sanitizeBoard(solverInitialBoard, boardSize);
setSolverMessage(t("animationReset"));
drawBoard();
syncProgressAfterBoardStateChange();
updateSolverControls();
}
// -----------------------------------------------------------------------------
// Board rules, rendering, and player interaction
function setMode(newMode) {
if (mode === newMode) {
return;
}
mode = newMode;
updateModeButtons();
updateModeContent();
resetBoard({
statusMessageText: t("modeActivated", { piece: getPieceLabel(mode) }),
statusColor: "#2563eb",
solverMessageText: t("solverReady", { modeLabel: getModeLabel(mode) })
});
}
function getConflicts(selectedBoard = board, selectedMode = mode) {
const conflicts = new Set();
const positions = getOccupiedPositions(selectedBoard);
for (let indexA = 0; indexA < positions.length; indexA++) {
const positionA = positions[indexA];
for (let indexB = indexA + 1; indexB < positions.length; indexB++) {
const positionB = positions[indexB];
const sameRow = positionA.row === positionB.row;
const sameColumn = positionA.col === positionB.col;
const sameDiagonal = Math.abs(positionA.col - positionB.col) === Math.abs(positionA.row - positionB.row);
const hasConflict = selectedMode === "rooks"
? sameRow || sameColumn
: sameRow || sameColumn || sameDiagonal;
if (hasConflict) {
conflicts.add(getCellKey(positionA.row, positionA.col));
conflicts.add(getCellKey(positionB.row, positionB.col));
}
}
}
return conflicts;
}
function isBoardSolved(selectedBoard = board, selectedMode = mode) {
return getPlacedPieceCount(selectedBoard) === boardSize && getConflicts(selectedBoard, selectedMode).size === 0;
}
function finishSolvedRun() {
if (isSolved) {
return;
}
elapsedBeforeTimerStartMs = getElapsedMilliseconds();
isSolved = true;
stopGameTimer();
updateProgressDisplay();
persistCurrentSession();
}
// The board is always rendered from state so manual moves, restored sessions,
// and solver playback all share the same visual path.
function drawBoard() {
boardDiv.innerHTML = "";
boardDiv.className = "board-grid";
updateBoardLayout();
const conflicts = getConflicts();
const highlight = currentSolverHighlight;
for (let row = -1; row < boardSize; row++) {
for (let col = -1; col < boardSize; col++) {
const cell = document.createElement("div");
if (row === -1 && col === -1) {
cell.classList.add("label");
} else if (row === -1) {
cell.textContent = getColumnLabel(col);
cell.classList.add("label");
} else if (col === -1) {
cell.textContent = boardSize - row;
cell.classList.add("label");
} else {
cell.classList.add("cell", (row + col) % 2 === 0 ? "white" : "black");
if (highlight && highlight.row === row && highlight.col === col) {
cell.classList.add(
highlight.type === "remove"
? "solver-remove"
: highlight.type === "solution"
? "solver-solution"
: "solver-place"
);
}
if (board[row][col] === OCCUPIED_CELL) {
cell.textContent = mode === "queens" ? "♛" : "♜";
if (conflicts.has(getCellKey(row, col))) {
cell.classList.add("invalid");
}
}
cell.onclick = () => placeQueen(row, col);
}
boardDiv.appendChild(cell);
}
}
updateQueenList();
}
function placeQueen(row, col) {
if (solverLoading) {
return;
}
ensureFreshRunAfterSolvedBoard();
if (hasPreparedSolverTrace() || isSolverPlaying()) {
discardSolverTrace(t("solverDiscardedManual"));
setStatus(t("solverDiscardedStatus"), "#d97706");
} else {
setStatus();
}
currentSolverHighlight = null;
board[row][col] = board[row][col] === OCCUPIED_CELL ? EMPTY_CELL : OCCUPIED_CELL;
stepCount += 1;
drawBoard();
updateProgressDisplay();
if (isBoardSolved()) {
finishSolvedRun();
setStatus(t("solvedPerfect", { duration: formatDuration(Math.floor(getElapsedMilliseconds() / 1000)) }), "green");
} else {
persistCurrentSession();
}
}
function resetBoard(options = {}) {
const {
statusMessageText = "",
statusColor = "",
solverMessageText = t("solverReady", { modeLabel: getModeLabel(mode) })
} = options;
discardSolverTrace(solverMessageText);
board = createEmptyBoard(boardSize);
currentSolverHighlight = null;
resetProgress();
if (statusMessageText) {
setStatus(statusMessageText, statusColor);
} else {
setStatus();
}
drawBoard();
}
// -----------------------------------------------------------------------------
// Panels and board settings
function previewBoardSize(size) {
pendingBoardSize = clampBoardSize(size);
sidebarBoardSizeValue.textContent = pendingBoardSize;
sidebarBoardSizeValueMirror.textContent = pendingBoardSize;
}
function applyBoardSize() {
boardSize = pendingBoardSize;
discardSolverTrace(t("solverReady", { modeLabel: getModeLabel(mode) }));
board = createEmptyBoard(boardSize);
currentSolverHighlight = null;
resetProgress();
updateBoardSizeDisplays(boardSize);
setStatus(t("settingsApplied", { size: boardSize }), "#2563eb");
drawBoard();
toggleSidebar(false);
}
function toggleSidebar(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !settingsSidebar.classList.contains("open");
if (shouldOpen) {
toggleSavePanel(false);
toggleSavePointsPanel(false);
}
settingsSidebar.classList.toggle("open", shouldOpen);
sidebarBackdrop.classList.toggle("visible", shouldOpen);
settingsSidebar.setAttribute("aria-hidden", String(!shouldOpen));
updateSettingsTrigger(shouldOpen);
}
function toggleSavePanel(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !saveSidebar.classList.contains("open");
if (shouldOpen) {
toggleSidebar(false);
toggleSavePointsPanel(false);
}
saveSidebar.classList.toggle("open", shouldOpen);
saveBackdrop.classList.toggle("visible", shouldOpen);
saveSidebar.setAttribute("aria-hidden", String(!shouldOpen));
}
function toggleSavePointsPanel(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !savePointsSidebar.classList.contains("open");
if (shouldOpen) {
toggleSidebar(false);
toggleSavePanel(false);
}
savePointsSidebar.classList.toggle("open", shouldOpen);
savePointsBackdrop.classList.toggle("visible", shouldOpen);
savePointsSidebar.setAttribute("aria-hidden", String(!shouldOpen));
}
function toggleInfoModal(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !infoModal.classList.contains("open");
infoModal.classList.toggle("open", shouldOpen);
infoModalBackdrop.classList.toggle("visible", shouldOpen);
infoModal.setAttribute("aria-hidden", String(!shouldOpen));
}
function getPlacedPieceCount(selectedBoard = board) {
return getOccupiedPositions(selectedBoard).length;
}
function resetSaveForm() {
saveNameInput.value = t("saveDefaultName", {
piece: getPieceLabel(mode),
size: boardSize,
count: getPlacedPieceCount()
});
saveNoteInput.value = "";
saveFavoriteInput.checked = false;
}
function openSavePanel() {
resetSaveForm();
toggleSavePanel(true);
}
function closeAllPanels() {
toggleSidebar(false);
toggleSavePanel(false);
toggleSavePointsPanel(false);
toggleInfoModal(false);
}
// -----------------------------------------------------------------------------
// Save point and history helpers
function getBoardPreview(savedBoard, limit = 4) {
const boardSizeForPreview = Array.isArray(savedBoard) ? savedBoard.length : DEFAULT_BOARD_SIZE;
const normalizedBoard = sanitizeBoard(savedBoard, boardSizeForPreview);
const preview = [];
for (const { row, col } of getOccupiedPositions(normalizedBoard)) {
preview.push(`${getColumnLabel(col)}${boardSizeForPreview - row}`);
if (preview.length >= limit) {
break;
}
}
return preview.length > 0 ? preview.join(", ") : t("previewEmpty");
}
function getSavePointFilters() {
const params = new URLSearchParams();
if (savePointModeFilter.value !== "all") {
params.set("mode", savePointModeFilter.value);
}
if (savePointSizeFilter.value !== "all") {
params.set("board_size", savePointSizeFilter.value);
}
params.set("sort", savePointSortFilter.value);
if (savePointFavoritesOnly.checked) {
params.set("favorites_only", "true");
}
return params.toString();
}
function createSavePointActionButton(label, className, onClick) {
const button = document.createElement("button");
button.className = className;
button.type = "button";
button.textContent = label;
button.onclick = onClick;
return button;
}
function renderSavePoints(savePoints) {
savePointsList.innerHTML = "";
if (savePoints.length === 0) {
savePointsList.innerHTML = `